diff --git a/.ci.yaml b/.ci.yaml index 92bfc040eecb..6b5c385aa98e 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -8,9 +8,57 @@ enabled_branches: - master +platform_properties: + linux: + properties: + caches: >- + [ + ] + dependencies: > + [ + {"dependency": "curl"} + ] + device_type: none + os: Linux + 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 + - name: Linux ci_yaml plugins roller + recipe: infra/ci_yaml + timeout: 30 + scheduler: luci + runIf: + - .ci.yaml diff --git a/.cirrus.yml b/.cirrus.yml index 8f69bd188c06..10d668d8d1d7 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -20,10 +20,28 @@ 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 +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. @@ -72,33 +90,36 @@ task: - cd script/tool - dart analyze --fatal-infos script: - - ./script/tool_runner.sh analyze + # 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 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 - - name: build-linux+drive-examples + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE + - name: linux-build+platform-tests env: matrix: CHANNEL: "master" @@ -106,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 @@ -125,7 +148,7 @@ task: memory: 12G matrix: ### Android tasks ### - - name: build-apks+java-test+firebase-test-lab + - name: android-build+platform-tests env: matrix: PLUGIN_SHARDING: "--shardIndex 0 --shardCount 4" @@ -144,13 +167,22 @@ task: - export CIRRUS_CHANGE_MESSAGE="" - export CIRRUS_COMMIT_MESSAGE="" - ./script/tool_runner.sh build-examples --apk - java_test_script: + 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 java-test # must come after apk build + - ./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. + # TODO(stuartmorgan): See https://github.com/flutter/flutter/issues/24935 + - export CIRRUS_CHANGE_MESSAGE="" + - 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 firebase_test_lab_script: # Unsetting CIRRUS_CHANGE_MESSAGE and CIRRUS_COMMIT_MESSAGE as they # might include non-ASCII characters which makes Gradle crash. @@ -159,16 +191,19 @@ 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=script/configs/exclude_integration_android.yaml - 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: - # 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" @@ -181,7 +216,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: @@ -195,18 +230,14 @@ 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 - - name: build-ipas+drive-examples + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE + - name: ios-build+platform-tests 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" @@ -221,27 +252,27 @@ 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 - xctest_script: - - ./script/tool_runner.sh xctest --ios --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" + 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" 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. - - ./script/tool_runner.sh drive-examples --ios --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS + # 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 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 - - name: build-macos+drive-examples + << : *BUILD_ALL_PLUGINS_APP_TEMPLATE + - name: macos-build+platform-tests 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" @@ -249,7 +280,9 @@ task: build_script: - flutter config --enable-macos-desktop - ./script/tool_runner.sh build-examples --macos - xctest_script: - - ./script/tool_runner.sh xctest --macos --exclude $PLUGINS_TO_EXCLUDE_MACOS_XCTESTS + xcode_analyze_script: + - ./script/tool_runner.sh xcode-analyze --macos + native_test_script: + - ./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/.github/workflows/release.yml b/.github/workflows/release.yml index f6753e5a2add..7f1a4a360949 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@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: | diff --git a/packages/android_alarm_manager/CHANGELOG.md b/packages/android_alarm_manager/CHANGELOG.md index 7c40428c22ba..d53b932e3f0f 100644 --- a/packages/android_alarm_manager/CHANGELOG.md +++ b/packages/android_alarm_manager/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +* Remove support for the V1 Android embedding. +* Updated Android lint settings. + ## 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 - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AlarmService.java b/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AlarmService.java index d333a4950026..aa59b578b157 100644 --- a/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AlarmService.java +++ b/packages/android_alarm_manager/android/src/main/java/io/flutter/plugins/androidalarmmanager/AlarmService.java @@ -49,8 +49,6 @@ public static void enqueueAlarmProcessing(Context context, Intent alarmContext) * */ 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) { *

*/ public void startBackgroundIsolate(Context context) { @@ -143,8 +123,6 @@ public void startBackgroundIsolate(Context context) { * */ 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/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/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/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_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/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/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<>(EmbedderV1Activity.class); -} 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/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/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 1f30104218e3..b141fab62595 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,3 +1,42 @@ +## 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. + +## 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) + +## 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. +* 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 + +* Fix device orientation sometimes not affecting the camera preview orientation. + +## 0.8.1+6 + +* Remove references to the Android V1 embedding. + +## 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/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/android/build.gradle b/packages/camera/camera/android/build.gradle index 65c6d26edb49..9bbafb653ef8 100644 --- a/packages/camera/camera/android/build.gradle +++ b/packages/camera/camera/android/build.gradle @@ -35,14 +35,25 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") } compileOptions { 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/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/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..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 @@ -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,44 @@ 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.CameraCaptureProperties; +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 +80,178 @@ 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; + /** True when the preview is paused. */ + private boolean pausedPreview; - 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; + /** Holds the last known capture properties */ + private CameraCaptureProperties captureProps; + + 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); + captureProps = new CameraCaptureProperties(); + cameraCaptureCallback = CameraCaptureCallback.create(this, captureTimeouts, captureProps); + + 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 +259,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 +273,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 +315,7 @@ public void onError(@NonNull CameraDevice cameraDevice, int errorCode) { dartMessenger.sendCameraErrorEvent(errorDescription); } }, - null); + backgroundHandler); } private void createCaptureSession(int templateType, Surface... surfaces) @@ -288,39 +330,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 +380,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 +390,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 +415,275 @@ 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())); + if (!pausedPreview) { + 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); - } - - @Override - public void onCaptureProgressed( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull CaptureResult partialResult) { - processCapture(partialResult); - } - - @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); - } + /** + * 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)); - private void processCapture(CaptureResult result) { - if (pictureCaptureRequest == null) { - return; - } + // Start precapture. + cameraCaptureCallback.setCameraState(CameraState.STATE_WAITING_PRECAPTURE_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(); - } - } - } - } - }; + previewRequestBuilder.set( + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START); - private void runPictureAutoFocus() { - assert (pictureCaptureRequest != null); + // Trigger one capture to start AE sequence. + captureSession.capture( + previewRequestBuilder.build(), cameraCaptureCallback, backgroundHandler); - 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; } - private void lockAutoFocus(CaptureCallback callback) { - captureRequestBuilder.set( + /** Start capturing a picture, doing autofocus first. */ + private void runPictureAutoFocus() { + Log.i(TAG, "runPictureAutoFocus"); + + cameraCaptureCallback.setCameraState(CameraState.STATE_WAITING_FOCUS); + lockAutoFocus(); + } + + 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 +693,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 +757,188 @@ 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; - } - - // 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; - } - - updateFlash(mode); - refreshPreviewCaptureSession( - () -> { - result.success(null); - isFinished = true; - }, - (code, message) -> - result.error("setFlashModeFailed", "Could not set flash mode.", null)); - } + /** + * 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); - @Override - public void onCaptureFailed( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull CaptureFailure failure) { - if (isFinished) { - return; - } + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> result.error("setFlashModeFailed", "Could not set flash mode.", null)); + } - result.error("setFlashModeFailed", "Could not set flash mode.", null); - isFinished = true; - } - }, - null); - } else { - updateFlash(mode); + /** + * 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); - refreshPreviewCaptureSession( - () -> result.success(null), - (code, message) -> result.error("setFlashModeFailed", "Could not set flash mode.", null)); - } + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> + result.error("setExposureModeFailed", "Could not set exposure mode.", null)); } - public void setExposureMode(@NonNull final Result result, ExposureMode mode) - throws CameraAccessException { - updateExposure(mode); - cameraCaptureSession.setRepeatingRequest(captureRequestBuilder.build(), null, null); - result.success(null); - } + /** + * 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); - 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)); + () -> result.success(null), + (code, message) -> + result.error("setExposurePointFailed", "Could not set exposure point.", null)); } - public void setFocusMode(@NonNull final Result result, FocusMode mode) - throws CameraAccessException { - this.focusMode = mode; - - updateFocus(mode); - - switch (mode) { - case auto: - refreshPreviewCaptureSession( - null, (code, message) -> result.error("setFocusMode", message, null)); - break; - case locked: - lockAutoFocus( - new CaptureCallback() { - @Override - public void onCaptureCompleted( - @NonNull CameraCaptureSession session, - @NonNull CaptureRequest request, - @NonNull TotalCaptureResult result) { - unlockAutoFocus(); - } - }); - break; - } - result.success(null); + /** Return the max exposure offset value supported by the camera to dart. */ + public double getMaxExposureOffset() { + return cameraFeatures.getExposureOffset().getMaxExposureOffset(); } - 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; - } - - // Check if the current region boundaries are known - if (cameraRegions.getMaxBoundaries() == null) { - result.error("setFocusPointFailed", "Could not determine max region boundaries", null); - return; - } - - // Set the metering rectangle - if (x == null || y == null) { - cameraRegions.resetAutoFocusMeteringRectangle(); - } else { - cameraRegions.setAutoFocusMeteringRectangleFromPoint(y, 1 - x); - } - - // Apply the new metering rectangle - setFocusMode(result, focusMode); + /** Return the min exposure offset value supported by the camera to dart. */ + public double getMinExposureOffset() { + return cameraFeatures.getExposureOffset().getMinExposureOffset(); } - @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; + /** Return the exposure offset step size to dart. */ + public double getExposureOffsetStepSize() { + return cameraFeatures.getExposureOffset().getExposureOffsetStepSize(); } - 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); + /** + * 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. + */ + 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; + } + break; + case auto: + // Cancel current AF trigger and set AF to idle again. + unlockAutoFocus(); + break; + } } - // 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); + + if (result != null) { + result.success(null); } - return rect == null ? null : new Size(rect.width(), rect.height()); } - private boolean isExposurePointSupported() throws CameraAccessException { - Integer supportedRegions = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.CONTROL_MAX_REGIONS_AE); - return supportedRegions != null && supportedRegions > 0; - } + /** + * 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 isFocusPointSupported() throws CameraAccessException { - Integer supportedRegions = - cameraManager - .getCameraCharacteristics(cameraDevice.getId()) - .get(CameraCharacteristics.CONTROL_MAX_REGIONS_AF); - return supportedRegions != null && supportedRegions > 0; - } + refreshPreviewCaptureSession( + () -> result.success(null), + (code, message) -> result.error("setFocusPointFailed", "Could not set focus point.", null)); - 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; + this.setFocusMode(null, cameraFeatures.getAutoFocus().getValue()); } - 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; + /** + * 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); + + refreshPreviewCaptureSession( + () -> result.success(exposureOffsetFeature.getValue()), + (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 +951,44 @@ 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); - } + cameraFeatures.getSensorOrientation().unlockCaptureOrientation(); } - 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); + /** Pause the preview from dart. */ + public void pausePreview() throws CameraAccessException { + this.pausedPreview = true; + this.captureSession.stopRepeating(); } - 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; - } + /** 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"); createCaptureSession(CameraDevice.TEMPLATE_PREVIEW, pictureImageReader.getSurface()); } @@ -1097,6 +996,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 +1007,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(); + Image img = reader.acquireNextImage(); + // Use acquireNextImage since image reader is only for one image. if (img == null) return; List> planes = new ArrayList<>(); @@ -1138,42 +1066,30 @@ private void setImageStreamImageAvailableListener(final EventChannel.EventSink i imageBuffer.put("height", img.getHeight()); imageBuffer.put("format", img.getFormat()); imageBuffer.put("planes", planes); - - imageStreamSink.success(imageBuffer); + 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)); 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 +1109,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/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/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..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 @@ -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); } @@ -331,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) { @@ -351,22 +375,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/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/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..fbed28bc11fc --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -0,0 +1,872 @@ +// Copyright 2013 The Flutter 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); + + 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(1.0); + } + + @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(); + } + + @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; + 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/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/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..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 @@ -23,4 +23,25 @@ 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); + } + } + + 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/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/EmbeddingV1ActivityTest.java b/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/cameraexample/EmbeddingV1ActivityTest.java deleted file mode 100644 index 9cd4ef60991b..000000000000 --- a/packages/camera/camera/example/android/app/src/androidTestDebug/java/io/flutter/plugins/cameraexample/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.cameraexample; - -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/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/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"> + +@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 +@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/ios/RunnerTests/CameraUtilTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraUtilTests.m new file mode 100644 index 000000000000..380f6e93de58 --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/CameraUtilTests.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 camera; +@import XCTest; +@import AVFoundation; +#import + +@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/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 00ac2251ba2a..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( @@ -530,7 +537,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, + ), ], ); } @@ -597,12 +613,14 @@ class _CameraExampleHomeState extends State if (controller != null) { await controller!.dispose(); } + final CameraController cameraController = CameraController( cameraDescription, ResolutionPreset.medium, enableAudio: enableAudio, imageFormatGroup: ImageFormatGroup.jpeg, ); + controller = cameraController; // If the controller is updated then update the UI. @@ -741,6 +759,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 ebd5366ba78d..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; @@ -118,7 +127,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. @@ -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. @@ -778,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/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/lib/src/camera_preview.dart b/packages/camera/camera/lib/src/camera_preview.dart index e2f1ff931e42..6a15896bfa47 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(); } @@ -55,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()]!; } @@ -65,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 789910e2c79b..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.8.1+4 +version: 0.9.2+2 environment: sdk: ">=2.12.0 <3.0.0" @@ -20,11 +20,12 @@ flutter: pluginClass: CameraPlugin dependencies: - camera_platform_interface: ^2.0.0 + camera_platform_interface: ^2.1.0 flutter: 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_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]), diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart index d579341c0e58..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() { @@ -146,7 +152,7 @@ void main() { RotatedBox rotatedBox = tester.widget(find.byType(RotatedBox)); - expect(rotatedBox.quarterTurns, 1); + expect(rotatedBox.quarterTurns, 3); debugDefaultTargetPlatformOverride = null; }); @@ -179,7 +185,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/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)'); }); }); } 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/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/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..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.'); @@ -235,6 +234,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}), + ]); + }); }); }); } diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md new file mode 100644 index 000000000000..a481554b540c --- /dev/null +++ b/packages/camera/camera_web/CHANGELOG.md @@ -0,0 +1,8 @@ +## 0.1.0+1 + +* Add `implements` to pubspec. + +## 0.1.0 + +* Initial release + * Added CameraOptions used to constrain the camera audio and video. 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..8c216b3f4e0e --- /dev/null +++ b/packages/camera/camera_web/README.md @@ -0,0 +1,77 @@ +# Camera Web Plugin + +The web implementation of [`camera`][camera]. + +*Note*: This plugin is under development. See [missing implementation](#missing-implementation). + +## 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 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_error_code_test.dart b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart new file mode 100644 index 000000000000..d0250c6e4e26 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart @@ -0,0 +1,171 @@ +// Copyright 2013 The Flutter 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 'package:integration_test/integration_test.dart'; + +import 'helpers/helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('CameraErrorCode', () { + group('toString returns a correct type for', () { + testWidgets('notSupported', (tester) async { + expect( + CameraErrorCode.notSupported.toString(), + equals('cameraNotSupported'), + ); + }); + + testWidgets('notFound', (tester) async { + expect( + CameraErrorCode.notFound.toString(), + equals('cameraNotFound'), + ); + }); + + testWidgets('notReadable', (tester) async { + expect( + CameraErrorCode.notReadable.toString(), + equals('cameraNotReadable'), + ); + }); + + testWidgets('overconstrained', (tester) async { + expect( + CameraErrorCode.overconstrained.toString(), + equals('cameraOverconstrained'), + ); + }); + + testWidgets('permissionDenied', (tester) async { + expect( + CameraErrorCode.permissionDenied.toString(), + equals('cameraPermission'), + ); + }); + + testWidgets('type', (tester) async { + expect( + CameraErrorCode.type.toString(), + equals('cameraType'), + ); + }); + + testWidgets('abort', (tester) async { + expect( + CameraErrorCode.abort.toString(), + equals('cameraAbort'), + ); + }); + + testWidgets('security', (tester) async { + expect( + CameraErrorCode.security.toString(), + equals('cameraSecurity'), + ); + }); + + testWidgets('missingMetadata', (tester) async { + expect( + CameraErrorCode.missingMetadata.toString(), + equals('cameraMissingMetadata'), + ); + }); + + testWidgets('orientationNotSupported', (tester) async { + expect( + CameraErrorCode.orientationNotSupported.toString(), + equals('orientationNotSupported'), + ); + }); + + testWidgets('torchModeNotSupported', (tester) async { + expect( + CameraErrorCode.torchModeNotSupported.toString(), + equals('torchModeNotSupported'), + ); + }); + + testWidgets('zoomLevelNotSupported', (tester) async { + expect( + CameraErrorCode.zoomLevelNotSupported.toString(), + equals('zoomLevelNotSupported'), + ); + }); + + testWidgets('zoomLevelInvalid', (tester) async { + expect( + CameraErrorCode.zoomLevelInvalid.toString(), + equals('zoomLevelInvalid'), + ); + }); + + testWidgets('notStarted', (tester) async { + expect( + CameraErrorCode.notStarted.toString(), + equals('cameraNotStarted'), + ); + }); + + testWidgets('unknown', (tester) async { + expect( + CameraErrorCode.unknown.toString(), + equals('cameraUnknown'), + ); + }); + + group('fromMediaError', () { + testWidgets('with aborted error code', (tester) async { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_ABORTED), + ).toString(), + equals('mediaErrorAborted'), + ); + }); + + testWidgets('with network error code', (tester) async { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_NETWORK), + ).toString(), + equals('mediaErrorNetwork'), + ); + }); + + testWidgets('with decode error code', (tester) async { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_DECODE), + ).toString(), + equals('mediaErrorDecode'), + ); + }); + + testWidgets('with source not supported error code', (tester) async { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED), + ).toString(), + equals('mediaErrorSourceNotSupported'), + ); + }); + + testWidgets('with unknown error code', (tester) async { + expect( + CameraErrorCode.fromMediaError( + FakeMediaError(5), + ).toString(), + equals('mediaErrorUnknown'), + ); + }); + }); + }); + }); +} diff --git a/packages/camera/camera_web/example/integration_test/camera_metadata_test.dart b/packages/camera/camera_web/example/integration_test/camera_metadata_test.dart new file mode 100644 index 000000000000..36ecb3e47f31 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_metadata_test.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 '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', () { + testWidgets('supports value equality', (tester) async { + expect( + CameraMetadata( + deviceId: 'deviceId', + facingMode: 'environment', + ), + equals( + CameraMetadata( + deviceId: 'deviceId', + facingMode: 'environment', + ), + ), + ); + }); + }); +} diff --git a/packages/camera/camera_web/example/integration_test/camera_options_test.dart b/packages/camera/camera_web/example/integration_test/camera_options_test.dart new file mode 100644 index 000000000000..a74ba3088394 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_options_test.dart @@ -0,0 +1,203 @@ +// Copyright 2013 The Flutter 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 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('CameraOptions', () { + testWidgets('serializes correctly', (tester) async { + 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(), + }), + ); + }); + + testWidgets('supports value equality', (tester) async { + 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', () { + testWidgets('serializes correctly', (tester) async { + expect( + AudioConstraints(enabled: true).toJson(), + equals(true), + ); + }); + + testWidgets('supports value equality', (tester) async { + expect( + AudioConstraints(enabled: true), + equals(AudioConstraints(enabled: true)), + ); + }); + }); + + group('VideoConstraints', () { + testWidgets('serializes correctly', (tester) async { + 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', + } + }), + ); + }); + + testWidgets('supports value equality', (tester) async { + 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', () { + testWidgets( + 'serializes correctly ' + 'for environment camera type', (tester) async { + expect( + FacingModeConstraint(CameraType.environment).toJson(), + equals({'ideal': 'environment'}), + ); + }); + + testWidgets( + 'serializes correctly ' + 'for user camera type', (tester) async { + expect( + FacingModeConstraint(CameraType.user).toJson(), + equals({'ideal': 'user'}), + ); + }); + + testWidgets('supports value equality', (tester) async { + expect( + FacingModeConstraint(CameraType.user), + equals(FacingModeConstraint(CameraType.user)), + ); + }); + }); + + group('exact', () { + testWidgets( + 'serializes correctly ' + 'for environment camera type', (tester) async { + expect( + FacingModeConstraint.exact(CameraType.environment).toJson(), + equals({'exact': 'environment'}), + ); + }); + + testWidgets( + 'serializes correctly ' + 'for user camera type', (tester) async { + expect( + FacingModeConstraint.exact(CameraType.user).toJson(), + equals({'exact': 'user'}), + ); + }); + + testWidgets('supports value equality', (tester) async { + expect( + FacingModeConstraint.exact(CameraType.environment), + equals(FacingModeConstraint.exact(CameraType.environment)), + ); + }); + }); + }); + + group('VideoSizeConstraint ', () { + testWidgets('serializes correctly', (tester) async { + expect( + VideoSizeConstraint( + minimum: 200, + ideal: 400, + maximum: 400, + ).toJson(), + equals({ + 'min': 200, + 'ideal': 400, + 'max': 400, + }), + ); + }); + + testWidgets('supports value equality', (tester) async { + expect( + VideoSizeConstraint( + minimum: 100, + ideal: 200, + maximum: 300, + ), + equals( + VideoSizeConstraint( + minimum: 100, + ideal: 200, + maximum: 300, + ), + ), + ); + }); + }); +} 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 new file mode 100644 index 000000000000..346ab26237ea --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_service_test.dart @@ -0,0 +1,869 @@ +// Copyright 2013 The Flutter 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 '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_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'; +import 'package:integration_test/integration_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'helpers/helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('CameraService', () { + const cameraId = 0; + + late Window window; + 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; + }); + + 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 cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.getMediaStreamForOptions( + CameraOptions(), + cameraId: cameraId, + ), + throwsA( + isA() + .having((e) => e.cameraId, 'cameraId', cameraId) + .having((e) => e.code, 'code', CameraErrorCode.unknown), + ), + ); + }); + }); + }); + + 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)); + + cameraService.jsUtil = jsUtil; + }); + + 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 = + cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.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( + () => cameraService.getZoomLevelCapabilityForCamera(camera), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + camera.textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notStarted, + ), + ), + ); + }); + }); + }); + + group('getFacingModeForVideoTrack', () { + setUp(() { + cameraService.jsUtil = jsUtil; + }); + + testWidgets( + 'throws PlatformException ' + 'with notSupported error ' + 'when there are no media devices', (tester) async { + when(() => navigator.mediaDevices).thenReturn(null); + + expect( + () => + cameraService.getFacingModeForVideoTrack(MockMediaStreamTrack()), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported.toString(), + ), + ), + ); + }); + + testWidgets( + 'returns null ' + 'when the facing mode is not supported', (tester) async { + when(mediaDevices.getSupportedConstraints).thenReturn({ + 'facingMode': false, + }); + + final facingMode = + cameraService.getFacingModeForVideoTrack(MockMediaStreamTrack()); + + 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, + }); + }); + + testWidgets( + 'returns an appropriate facing mode ' + 'based on the video track settings', (tester) async { + when(videoTrack.getSettings).thenReturn({'facingMode': 'user'}); + + final facingMode = + cameraService.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 { + 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')); + }); + + testWidgets( + 'returns null ' + 'when the facing mode setting ' + 'and capabilities are empty', (tester) async { + when(videoTrack.getSettings).thenReturn({}); + when(videoTrack.getCapabilities).thenReturn({'facingMode': []}); + + final facingMode = + cameraService.getFacingModeForVideoTrack(videoTrack); + + expect(facingMode, isNull); + }); + + testWidgets( + 'returns null ' + 'when the facing mode setting is empty and ' + 'the video track capabilities are not supported', (tester) async { + when(videoTrack.getSettings).thenReturn({}); + + when(() => jsUtil.hasProperty(videoTrack, 'getCapabilities')) + .thenReturn(false); + + final facingMode = + cameraService.getFacingModeForVideoTrack(videoTrack); + + expect(facingMode, isNull); + }); + }); + }); + + group('mapFacingModeToLensDirection', () { + testWidgets( + 'returns front ' + 'when the facing mode is user', (tester) async { + expect( + cameraService.mapFacingModeToLensDirection('user'), + equals(CameraLensDirection.front), + ); + }); + + testWidgets( + 'returns back ' + 'when the facing mode is environment', (tester) async { + expect( + cameraService.mapFacingModeToLensDirection('environment'), + equals(CameraLensDirection.back), + ); + }); + + testWidgets( + 'returns external ' + 'when the facing mode is left', (tester) async { + expect( + cameraService.mapFacingModeToLensDirection('left'), + equals(CameraLensDirection.external), + ); + }); + + testWidgets( + 'returns external ' + 'when the facing mode is right', (tester) async { + expect( + cameraService.mapFacingModeToLensDirection('right'), + equals(CameraLensDirection.external), + ); + }); + }); + + group('mapFacingModeToCameraType', () { + testWidgets( + 'returns user ' + 'when the facing mode is user', (tester) async { + expect( + cameraService.mapFacingModeToCameraType('user'), + equals(CameraType.user), + ); + }); + + testWidgets( + 'returns environment ' + 'when the facing mode is environment', (tester) async { + expect( + cameraService.mapFacingModeToCameraType('environment'), + equals(CameraType.environment), + ); + }); + + testWidgets( + 'returns user ' + 'when the facing mode is left', (tester) async { + expect( + cameraService.mapFacingModeToCameraType('left'), + equals(CameraType.user), + ); + }); + + testWidgets( + 'returns user ' + 'when the facing mode is right', (tester) async { + expect( + cameraService.mapFacingModeToCameraType('right'), + equals(CameraType.user), + ); + }); + }); + + group('mapResolutionPresetToSize', () { + testWidgets( + 'returns 4096x2160 ' + 'when the resolution preset is max', (tester) async { + expect( + cameraService.mapResolutionPresetToSize(ResolutionPreset.max), + equals(Size(4096, 2160)), + ); + }); + + testWidgets( + 'returns 4096x2160 ' + 'when the resolution preset is ultraHigh', (tester) async { + expect( + cameraService.mapResolutionPresetToSize(ResolutionPreset.ultraHigh), + equals(Size(4096, 2160)), + ); + }); + + testWidgets( + 'returns 1920x1080 ' + 'when the resolution preset is veryHigh', (tester) async { + expect( + cameraService.mapResolutionPresetToSize(ResolutionPreset.veryHigh), + equals(Size(1920, 1080)), + ); + }); + + testWidgets( + 'returns 1280x720 ' + 'when the resolution preset is high', (tester) async { + expect( + cameraService.mapResolutionPresetToSize(ResolutionPreset.high), + equals(Size(1280, 720)), + ); + }); + + testWidgets( + 'returns 720x480 ' + 'when the resolution preset is medium', (tester) async { + expect( + cameraService.mapResolutionPresetToSize(ResolutionPreset.medium), + equals(Size(720, 480)), + ); + }); + + testWidgets( + 'returns 320x240 ' + 'when the resolution preset is low', (tester) async { + expect( + cameraService.mapResolutionPresetToSize(ResolutionPreset.low), + equals(Size(320, 240)), + ); + }); + }); + + group('mapDeviceOrientationToOrientationType', () { + testWidgets( + 'returns portraitPrimary ' + 'when the device orientation is portraitUp', (tester) async { + expect( + cameraService.mapDeviceOrientationToOrientationType( + DeviceOrientation.portraitUp, + ), + equals(OrientationType.portraitPrimary), + ); + }); + + testWidgets( + 'returns landscapePrimary ' + 'when the device orientation is landscapeLeft', (tester) async { + expect( + cameraService.mapDeviceOrientationToOrientationType( + DeviceOrientation.landscapeLeft, + ), + equals(OrientationType.landscapePrimary), + ); + }); + + testWidgets( + 'returns portraitSecondary ' + 'when the device orientation is portraitDown', (tester) async { + expect( + cameraService.mapDeviceOrientationToOrientationType( + DeviceOrientation.portraitDown, + ), + equals(OrientationType.portraitSecondary), + ); + }); + + testWidgets( + 'returns landscapeSecondary ' + 'when the device orientation is landscapeRight', (tester) async { + expect( + cameraService.mapDeviceOrientationToOrientationType( + DeviceOrientation.landscapeRight, + ), + equals(OrientationType.landscapeSecondary), + ); + }); + }); + + group('mapOrientationTypeToDeviceOrientation', () { + testWidgets( + 'returns portraitUp ' + 'when the orientation type is portraitPrimary', (tester) async { + expect( + cameraService.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitPrimary, + ), + equals(DeviceOrientation.portraitUp), + ); + }); + + testWidgets( + 'returns landscapeLeft ' + 'when the orientation type is landscapePrimary', (tester) async { + expect( + cameraService.mapOrientationTypeToDeviceOrientation( + OrientationType.landscapePrimary, + ), + equals(DeviceOrientation.landscapeLeft), + ); + }); + + testWidgets( + 'returns portraitDown ' + 'when the orientation type is portraitSecondary', (tester) async { + expect( + cameraService.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitSecondary, + ), + equals(DeviceOrientation.portraitDown), + ); + }); + + testWidgets( + 'returns portraitDown ' + 'when the orientation type is portraitSecondary', (tester) async { + expect( + cameraService.mapOrientationTypeToDeviceOrientation( + OrientationType.portraitSecondary, + ), + equals(DeviceOrientation.portraitDown), + ); + }); + + testWidgets( + 'returns landscapeRight ' + 'when the orientation type is landscapeSecondary', (tester) async { + expect( + cameraService.mapOrientationTypeToDeviceOrientation( + OrientationType.landscapeSecondary, + ), + equals(DeviceOrientation.landscapeRight), + ); + }); + + testWidgets( + 'returns portraitUp ' + 'for an unknown orientation type', (tester) async { + expect( + cameraService.mapOrientationTypeToDeviceOrientation( + 'unknown', + ), + equals(DeviceOrientation.portraitUp), + ); + }); + }); + }); +} + +class JSNoSuchMethodError implements Exception {} 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..712d8c77ff3e --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -0,0 +1,1032 @@ +// Copyright 2013 The Flutter 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 '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'; +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('Camera', () { + const textureId = 1; + + late Window window; + late Navigator navigator; + late MediaDevices mediaDevices; + + late MediaStream mediaStream; + late CameraService cameraService; + + setUp(() { + window = MockWindow(); + navigator = MockNavigator(); + mediaDevices = MockMediaDevices(); + + when(() => window.navigator).thenReturn(navigator); + when(() => navigator.mediaDevices).thenReturn(mediaDevices); + + cameraService = MockCameraService(); + + final videoElement = getVideoElementWithBlankStream(Size(10, 10)); + mediaStream = videoElement.captureStream(); + + when( + () => cameraService.getMediaStreamForOptions( + any(), + cameraId: any(named: 'cameraId'), + ), + ).thenAnswer((_) => Future.value(mediaStream)); + }); + + setUpAll(() { + registerFallbackValue(MockCameraOptions()); + }); + + group('initialize', () { + testWidgets( + 'calls CameraService.getMediaStreamForOptions ' + 'with provided options', (tester) async { + final options = CameraOptions( + video: VideoConstraints( + facingMode: FacingModeConstraint.exact(CameraType.user), + width: VideoSizeConstraint(ideal: 200), + ), + ); + + final camera = Camera( + textureId: textureId, + options: options, + cameraService: cameraService, + ); + + await camera.initialize(); + + verify( + () => cameraService.getMediaStreamForOptions( + options, + cameraId: textureId, + ), + ).called(1); + }); + + testWidgets( + '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, + ); + + await camera.initialize(); + + expect(camera.videoElement, isNotNull); + expect(camera.videoElement.autoplay, isFalse); + expect(camera.videoElement.muted, isTrue); + 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')); + }); + + 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)')); + }); + + testWidgets( + 'creates a wrapping div element ' + 'with correct properties', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + + expect(camera.divElement, isNotNull); + expect(camera.divElement.style.objectFit, equals('cover')); + expect(camera.divElement.children, contains(camera.videoElement)); + }); + + testWidgets('initializes the camera stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + + expect(camera.stream, mediaStream); + }); + + testWidgets( + 'throws an exception ' + 'when CameraService.getMediaStreamForOptions throws', (tester) async { + final exception = Exception('A media stream exception occured.'); + + when(() => cameraService.getMediaStreamForOptions(any(), + cameraId: any(named: 'cameraId'))).thenThrow(exception); + + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + expect( + camera.initialize, + throwsA(exception), + ); + }); + }); + + group('play', () { + testWidgets('starts playing the video element', (tester) async { + var startedPlaying = false; + + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + + final cameraPlaySubscription = + camera.videoElement.onPlay.listen((event) => startedPlaying = true); + + await camera.play(); + + expect(startedPlaying, isTrue); + + await cameraPlaySubscription.cancel(); + }); + + testWidgets( + 'initializes the camera stream ' + 'from CameraService.getMediaStreamForOptions ' + 'if it does not exist', (tester) async { + final options = CameraOptions( + video: VideoConstraints( + width: VideoSizeConstraint(ideal: 100), + ), + ); + + final camera = Camera( + textureId: textureId, + options: options, + cameraService: cameraService, + ); + + await camera.initialize(); + + /// Remove the video element's source + /// by stopping the camera. + camera.stop(); + + await camera.play(); + + // Should be called twice: for initialize and play. + verify( + () => cameraService.getMediaStreamForOptions( + options, + cameraId: textureId, + ), + ).called(2); + + expect(camera.videoElement.srcObject, mediaStream); + expect(camera.stream, mediaStream); + }); + }); + + 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( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.play(); + + camera.stop(); + + expect(camera.videoElement.srcObject, isNull); + expect(camera.stream, isNull); + }); + }); + + group('takePicture', () { + testWidgets('returns a captured picture', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.play(); + + final pictureFile = await camera.takePicture(); + + 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, + cameraService: cameraService, + ) + ..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, + cameraService: cameraService, + ) + ..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', () { + 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: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + + expect( + 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: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + + expect( + camera.getVideoSize(), + equals(Size.zero), + ); + }); + }); + + 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, + cameraService: cameraService, + ) + ..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, + cameraService: cameraService, + ) + ..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, + cameraService: cameraService, + ) + ..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, + cameraService: cameraService, + ) + ..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, + cameraService: cameraService, + ) + ..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, + cameraService: cameraService, + ) + ..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, + cameraService: cameraService, + )..window = window; + + expect( + () => camera.setFlashMode(FlashMode.always), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notStarted, + ), + ), + ); + }); + }); + }); + + group('zoomLevel', () { + group('getMaxZoomLevel', () { + testWidgets( + 'returns maximum ' + 'from CameraService.getZoomLevelCapabilityForCamera', + (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) + .thenReturn(zoomLevelCapability); + + final maximumZoomLevel = camera.getMaxZoomLevel(); + + verify(() => cameraService.getZoomLevelCapabilityForCamera(camera)) + .called(1); + + expect( + maximumZoomLevel, + equals(zoomLevelCapability.maximum), + ); + }); + }); + + group('getMinZoomLevel', () { + testWidgets( + 'returns minimum ' + 'from CameraService.getZoomLevelCapabilityForCamera', + (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) + .thenReturn(zoomLevelCapability); + + final minimumZoomLevel = camera.getMinZoomLevel(); + + verify(() => cameraService.getZoomLevelCapabilityForCamera(camera)) + .called(1); + + expect( + minimumZoomLevel, + equals(zoomLevelCapability.minimum), + ); + }); + }); + + group('setZoomLevel', () { + testWidgets( + 'applies zoom on the video track ' + 'from CameraService.getZoomLevelCapabilityForCamera', + (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + final videoTrack = MockMediaStreamTrack(); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: videoTrack, + ); + + when(() => videoTrack.applyConstraints(any())) + .thenAnswer((_) async {}); + + when(() => cameraService.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, + cameraService: cameraService, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraService.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, + cameraService: cameraService, + ); + + final zoomLevelCapability = ZoomLevelCapability( + minimum: 50.0, + maximum: 100.0, + videoTrack: MockMediaStreamTrack(), + ); + + when(() => cameraService.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('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( + textureId: textureId, + cameraService: cameraService, + ); + + 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( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + + 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_exception_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_exception_test.dart new file mode 100644 index 000000000000..6f8531b6f4af --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_web_exception_test.dart @@ -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. + +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', () { + testWidgets('sets all properties', (tester) async { + 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)); + }); + + testWidgets('toString includes all properties', (tester) async { + 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)'), + ); + }); + }); +} 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..4bc10badab05 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -0,0 +1,2362 @@ +// Copyright 2013 The Flutter 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'; +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'; +import 'package:camera_web/src/camera_service.dart'; +import 'package:camera_web/src/types/types.dart'; +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'; + +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; + late Screen screen; + late ScreenOrientation screenOrientation; + late Document document; + late Element documentElement; + + late CameraService cameraService; + + setUp(() async { + window = MockWindow(); + navigator = MockNavigator(); + mediaDevices = MockMediaDevices(); + + videoElement = getVideoElementWithBlankStream(Size(10, 10)); + + 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); + + cameraService = MockCameraService(); + + when( + () => cameraService.getMediaStreamForOptions( + any(), + cameraId: any(named: 'cameraId'), + ), + ).thenAnswer( + (_) async => videoElement.captureStream(), + ); + + CameraPlatform.instance = CameraPlugin( + cameraService: cameraService, + )..window = window; + }); + + setUpAll(() { + registerFallbackValue(MockMediaStreamTrack()); + registerFallbackValue(MockCameraOptions()); + registerFallbackValue(FlashMode.off); + }); + + testWidgets('CameraPlugin is the live instance', (tester) async { + expect(CameraPlatform.instance, isA()); + }); + + group('availableCameras', () { + setUp(() { + when( + () => cameraService.getFacingModeForVideoTrack( + any(), + ), + ).thenReturn(null); + + when(mediaDevices.enumerateDevices).thenAnswer( + (_) async => [], + ); + }); + + testWidgets('requests video and audio permissions', (tester) async { + final _ = await CameraPlatform.instance.availableCameras(); + + verify( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + audio: AudioConstraints(enabled: true), + ), + ), + ).called(1); + }); + + testWidgets( + 'gets a video stream ' + 'for a 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( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + video: VideoConstraints( + deviceId: videoDevice.deviceId, + ), + ), + ), + ).called(1); + }); + + testWidgets( + '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( + () => cameraService.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', + MediaDeviceKind.videoInput, + ); + + final videoStream = + FakeMediaStream([MockMediaStreamTrack(), MockMediaStreamTrack()]); + + when( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + video: VideoConstraints(deviceId: videoDevice.deviceId), + ), + ), + ).thenAnswer((_) => Future.value(videoStream)); + + when(mediaDevices.enumerateDevices).thenAnswer( + (_) => Future.value([videoDevice]), + ); + + final _ = await CameraPlatform.instance.availableCameras(); + + verify( + () => cameraService.getFacingModeForVideoTrack( + videoStream.getVideoTracks().first, + ), + ).called(1); + }); + + testWidgets( + 'returns appropriate camera descriptions ' + 'for multiple video devices ' + 'based on video streams', (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', + 'Audio Input 2', + MediaDeviceKind.audioInput, + ), + FakeMediaDeviceInfo( + '3', + 'Audio Output 3', + MediaDeviceKind.audioOutput, + ), + secondVideoDevice, + ]), + ); + + // Mock camera service to return the first video stream + // for the first video device. + when( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + video: VideoConstraints(deviceId: firstVideoDevice.deviceId), + ), + ), + ).thenAnswer((_) => Future.value(firstVideoStream)); + + // Mock camera service to return the second video stream + // for the second video device. + when( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + video: VideoConstraints(deviceId: secondVideoDevice.deviceId), + ), + ), + ).thenAnswer((_) => Future.value(secondVideoStream)); + + // Mock camera service to return a user facing mode + // for the first video stream. + when( + () => cameraService.getFacingModeForVideoTrack( + firstVideoStream.getVideoTracks().first, + ), + ).thenReturn('user'); + + when(() => cameraService.mapFacingModeToLensDirection('user')) + .thenReturn(CameraLensDirection.front); + + // Mock camera service to return an environment facing mode + // for the second video stream. + when( + () => cameraService.getFacingModeForVideoTrack( + secondVideoStream.getVideoTracks().first, + ), + ).thenReturn('environment'); + + when(() => cameraService.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( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + video: VideoConstraints(deviceId: videoDevice.deviceId), + ), + ), + ).thenAnswer((_) => Future.value(videoStream)); + + when( + () => cameraService.getFacingModeForVideoTrack( + videoStream.getVideoTracks().first, + ), + ).thenReturn('left'); + + when(() => cameraService.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', + ) + }), + ); + }); + + 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(), + ), + ), + ); + }); + + 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 CameraService.getMediaStreamForOptions ' + 'throws CameraWebException', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.security, + 'description', + ); + + when(() => cameraService.getMediaStreamForOptions(any())) + .thenThrow(exception); + + expect( + () => CameraPlatform.instance.availableCameras(), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + + testWidgets( + 'when CameraService.getMediaStreamForOptions ' + 'throws PlatformException', (tester) async { + final exception = PlatformException( + code: CameraErrorCode.notSupported.toString(), + message: 'message', + ); + + when(() => cameraService.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); + + final cameraDescription = CameraDescription( + name: 'name', + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ); + + final cameraMetadata = CameraMetadata( + deviceId: 'deviceId', + facingMode: 'user', + ); + + setUp(() { + // Add metadata for the camera description. + (CameraPlatform.instance as CameraPlugin) + .camerasMetadata[cameraDescription] = cameraMetadata; + + when( + () => cameraService.mapFacingModeToCameraType('user'), + ).thenReturn(CameraType.user); + }); + + testWidgets('with appropriate options', (tester) async { + when( + () => cameraService + .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.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( + () => cameraService.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( + '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', + CameraErrorCode.missingMetadata.toString(), + ), + ), + ); + }); + }); + + group('initializeCamera', () { + 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).thenReturn(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; + + await CameraPlatform.instance.initializeCamera(cameraId); + + verify(camera.initialize).called(1); + 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 ' + '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(), + ), + ), + ); + }); + }); + }); + + group('lockCaptureOrientation', () { + setUp(() { + when( + () => cameraService.mapDeviceOrientationToOrientationType(any()), + ).thenReturn(OrientationType.portraitPrimary); + }); + + testWidgets( + 'requests full-screen mode ' + 'on documentElement', (tester) async { + await CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.portraitUp, + ); + + verify(documentElement.requestFullscreen).called(1); + }); + + testWidgets( + 'locks the capture orientation ' + 'based on the given device orientation', (tester) async { + when( + () => cameraService.mapDeviceOrientationToOrientationType( + DeviceOrientation.landscapeRight, + ), + ).thenReturn(OrientationType.landscapeSecondary); + + await CameraPlatform.instance.lockCaptureOrientation( + cameraId, + DeviceOrientation.landscapeRight, + ); + + verify( + () => cameraService.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, + ), + ), + ); + }); + }); + }); + + group('unlockCaptureOrientation', () { + setUp(() { + when( + () => cameraService.mapDeviceOrientationToOrientationType(any()), + ).thenReturn(OrientationType.portraitPrimary); + }); + + 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', () { + 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)); + }); + + 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('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(), + ), + ), + ); + }); + }); + }); + + 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, + ); + }); + + 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 ' + '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 { + 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, + ); + }); + + 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(), + ), + ), + ); + }); + }); + }); + + 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(), + ), + ), + ); + }); + }); + }); + + 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(), + ), + ), + ); + }); + }); + }); + + 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 { + final camera = Camera( + textureId: cameraId, + cameraService: cameraService, + ); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + CameraPlatform.instance.buildPreview(cameraId), + isA().having( + (view) => view.viewType, + 'viewType', + camera.getViewType(), + ), + ); + }); + + 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).thenReturn(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; + + 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, + }), + ); + }); + + testWidgets('cancels the camera video error and abort 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.dispose(cameraId); + + 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); + await CameraPlatform.instance.dispose(cameraId); + + expect(endedStreamController.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, + cameraService: cameraService, + ); + + // 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 PlatformException ' + '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', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + }); + + 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).thenReturn(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 { + // Mock the camera to use a blank video stream of size 1280x720. + const videoSize = Size(1280, 720); + + videoElement = getVideoElementWithBlankStream(videoSize); + + when( + () => cameraService.getMediaStreamForOptions( + any(), + cameraId: cameraId, + ), + ).thenAnswer((_) async => videoElement.captureStream()); + + final camera = Camera( + textureId: cameraId, + cameraService: cameraService, + ); + + // 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( + await streamQueue.next, + equals( + CameraInitializedEvent( + cameraId, + videoSize.width, + videoSize.height, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets('onCameraResolutionChanged emits an empty stream', + (tester) async { + expect( + CameraPlatform.instance.onCameraResolutionChanged(cameraId), + emits(isEmpty), + ); + }); + + 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; + + final Stream eventStream = + CameraPlatform.instance.onCameraClosing(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + + endedStreamController.add(MockMediaStreamTrack()); + + expect( + await streamQueue.next, + equals( + CameraClosingEvent(cameraId), + ), + ); + + 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 the camera video error event ' + '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 the camera video error event ' + '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 the camera video abort event', (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( + '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 { + 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( + '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( + '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', + (tester) async { + expect( + () => CameraPlatform.instance.onVideoRecordedEvent(cameraId), + 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 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 { + when( + () => cameraService.mapOrientationTypeToDeviceOrientation( + OrientationType.landscapePrimary, + ), + ).thenReturn(DeviceOrientation.landscapeLeft); + + when( + () => cameraService.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('change')); + + 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('change')); + + expect( + await streamQueue.next, + equals( + DeviceOrientationChangedEvent( + DeviceOrientation.portraitDown, + ), + ), + ); + + await streamQueue.cancel(); + }); + }); + }); + }); +} 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..e6a11cc0b454 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -0,0 +1,142 @@ +// Copyright 2013 The Flutter 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'; +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'; + +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 {} + +class MockCameraService extends Mock implements CameraService {} + +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 {} + +class MockJsUtil extends Mock implements JsUtil {} + +/// 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 [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, [ + 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]. +/// +/// 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/example/integration_test/zoom_level_capability_test.dart b/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart new file mode 100644 index 000000000000..09de03100871 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/zoom_level_capability_test.dart @@ -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 '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'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('ZoomLevelCapability', () { + testWidgets('sets all properties', (tester) async { + 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)); + }); + + testWidgets('supports value equality', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + expect( + ZoomLevelCapability( + minimum: 0.0, + maximum: 100.0, + videoTrack: videoTrack, + ), + equals( + ZoomLevelCapability( + minimum: 0.0, + maximum: 100.0, + videoTrack: videoTrack, + ), + ), + ); + }); + }); +} 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.dart b/packages/camera/camera_web/lib/src/camera.dart new file mode 100644 index 000000000000..4b7a185b90f7 --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -0,0 +1,401 @@ +// Copyright 2013 The Flutter 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:ui'; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/src/camera_service.dart'; +import 'package:camera_web/src/types/types.dart'; +import 'package:flutter/foundation.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. +/// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices +/// +/// The obtained camera stream is constrained by [options] and fetched +/// with [CameraService.getMediaStreamForOptions]. +/// +/// 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 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 { + /// Creates a new instance of [Camera] + /// with the given [textureId] and optional + /// [options] and [window]. + Camera({ + required this.textureId, + required CameraService cameraService, + this.options = const CameraOptions(), + }) : _cameraService = cameraService; + + // 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; + + /// The camera options used to initialize a camera, empty by default. + final CameraOptions options; + + /// The video element that displays the camera stream. + /// Initialized in [initialize]. + late final html.VideoElement videoElement; + + /// The wrapping element for the [videoElement] to avoid overriding + /// the custom styles applied in [_applyDefaultVideoStyles]. + /// Initialized in [initialize]. + late final html.DivElement divElement; + + /// The camera stream displayed in the [videoElement]. + /// 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; + + /// 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 + html.Window? window = html.window; + + /// 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, + cameraId: textureId, + ); + + videoElement = html.VideoElement(); + + divElement = html.DivElement() + ..style.setProperty('object-fit', 'cover') + ..append(videoElement); + + ui.platformViewRegistry.registerViewFactory( + _getViewType(textureId), + (_) => divElement, + ); + + videoElement + ..autoplay = false + ..muted = true + ..srcObject = stream + ..setAttribute('playsinline', ''); + + _applyDefaultVideoStyles(videoElement); + + final videoTracks = stream!.getVideoTracks(); + + if (videoTracks.isNotEmpty) { + final defaultVideoTrack = videoTracks.first; + + _onEndedSubscription = defaultVideoTrack.onEnded.listen((html.Event _) { + onEndedStreamController.add(defaultVideoTrack); + }); + } + } + + /// Starts the camera stream. + /// + /// Initializes the camera source if the camera was previously stopped. + Future play() async { + if (videoElement.srcObject == null) { + stream = await _cameraService.getMediaStreamForOptions( + options, + cameraId: textureId, + ); + videoElement.srcObject = stream; + } + await videoElement.play(); + } + + /// Pauses the camera stream on the current frame. + void pause() { + videoElement.pause(); + } + + /// Stops the camera stream and resets the camera source. + void stop() { + 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(); + } + } + videoElement.srcObject = null; + stream = null; + } + + /// Captures a picture and returns the saved file in a JPEG format. + /// + /// 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; + + if (shouldEnableTorchMode) { + _setTorchMode(enabled: true); + } + + 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 + .drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight); + + final blob = await canvas.toBlob('image/jpeg'); + + if (shouldEnableTorchMode) { + _setTorchMode(enabled: false); + } + + 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. + Size getVideoSize() { + 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; + } + } + + /// 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. + 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]. + /// + /// 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() ?? []; + + 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 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() => + _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() => + _cameraService.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 = + _cameraService.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 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); + + /// Disposes the camera by stopping the camera stream + /// and reloading the camera source. + Future dispose() async { + /// Stop the camera stream. + stop(); + + /// Reset the [videoElement] to its initial state. + videoElement + ..srcObject = null + ..load(); + + await _onEndedSubscription?.cancel(); + _onEndedSubscription = null; + + await onEndedStreamController.close(); + } + + /// 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'; + } +} diff --git a/packages/camera/camera_web/lib/src/camera_service.dart b/packages/camera/camera_web/lib/src/camera_service.dart new file mode 100644 index 000000000000..5ba5c80395cc --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera_service.dart @@ -0,0 +1,326 @@ +// Copyright 2013 The Flutter 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'; + +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'; + +/// A service to fetch, map camera settings and +/// obtain the camera stream. +class CameraService { + // A facing mode constraint name. + static const _facingModeKey = "facingMode"; + + /// The current browser window used to access media devices. + @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( + 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 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 = jsUtil.getProperty(zoomLevelCapability, 'min'); + final maximumZoomLevel = jsUtil.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) { + 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.', + ); + } + + // 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) { + // 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] ?? []); + + if (facingModeCapabilities.isNotEmpty) { + final facingModeCapability = facingModeCapabilities.first; + return facingModeCapability; + } else { + // Return null if there are no facing mode capabilities. + return null; + } + } + + 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; + } + } + + /// 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(4096, 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); + } + } + + /// 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 new file mode 100644 index 000000000000..5c976b8f8657 --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -0,0 +1,620 @@ +// Copyright 2013 The Flutter 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:camera_web/src/camera.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'; +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. +class CameraPlugin extends CameraPlatform { + /// Creates a new instance of [CameraPlugin] + /// 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( + cameraService: CameraService(), + ); + } + + final CameraService _cameraService; + + /// The cameras managed by the [CameraPlugin]. + @visibleForTesting + final cameras = {}; + var _textureCounter = 1; + + /// Metadata associated with each camera description. + /// Populated in [availableCameras]. + @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(); + + final _cameraVideoErrorSubscriptions = + >{}; + + final _cameraVideoAbortSubscriptions = + >{}; + + final _cameraEndedSubscriptions = + >{}; + + /// 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; + + @override + Future> availableCameras() async { + 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.', + ); + } + + // Request video and audio permissions. + await _cameraService.getMediaStreamForOptions( + CameraOptions( + audio: AudioConstraints(enabled: true), + ), + ); + + // 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!, + ); + + // 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 = + _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 + ? _cameraService.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; + } 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 + Future createCamera( + CameraDescription cameraDescription, + ResolutionPreset? resolutionPreset, { + bool enableAudio = false, + }) async { + 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 + ? _cameraService.mapFacingModeToCameraType(cameraMetadata.facingMode!) + : null; + + // Use the highest resolution possible + // if the resolution preset is not specified. + 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, + cameraService: _cameraService, + 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; + } on PlatformException catch (e) { + throw CameraException(e.code, e.message); + } + } + + @override + Future initializeCamera( + int cameraId, { + // The image format group is currently not supported. + ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, + }) async { + 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(); + + // 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 = 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 + Stream onCameraInitialized(int cameraId) { + 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) { + return const Stream.empty(); + } + + @override + Stream onCameraClosing(int cameraId) { + return _cameraEvents(cameraId).whereType(); + } + + @override + Stream onCameraError(int cameraId) { + return _cameraEvents(cameraId).whereType(); + } + + @override + Stream onVideoRecordedEvent(int cameraId) { + throw UnimplementedError('onVideoRecordedEvent() is not implemented.'); + } + + @override + Stream onDeviceOrientationChanged() { + final orientation = window?.screen?.orientation; + + if (orientation != null) { + // 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!); + return DeviceOrientationChangedEvent(deviceOrientation); + }, + ); + } else { + return const Stream.empty(); + } + } + + @override + Future lockCaptureOrientation( + int cameraId, + DeviceOrientation deviceOrientation, + ) async { + try { + final orientation = window?.screen?.orientation; + final documentElement = window?.document.documentElement; + + if (orientation != null && documentElement != null) { + final orientationType = _cameraService + .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) async { + try { + final orientation = window?.screen?.orientation; + final documentElement = window?.document.documentElement; + + if (orientation != null && documentElement != null) { + 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 + Future takePicture(int cameraId) { + try { + 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); + } + } + + @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) 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 + 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) 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) 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) 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 + 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( + viewType: getCamera(cameraId).getViewType(), + ); + } + + @override + Future dispose(int cameraId) async { + try { + 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); + } + } + + /// Returns a media video stream for the device with the given [deviceId]. + Future _getVideoStreamForDevice( + String deviceId, + ) { + // Create camera options with the desired device id. + final cameraOptions = CameraOptions( + video: VideoConstraints(deviceId: deviceId), + ); + + return _cameraService.getMediaStreamForOptions(cameraOptions); + } + + /// 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 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/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); +} 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_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart new file mode 100644 index 000000000000..210fa2baa9d2 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -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 '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'); + + /// The camera orientation is not supported. + static const CameraErrorCode orientationNotSupported = + CameraErrorCode._('orientationNotSupported'); + + /// The camera torch mode is not supported. + 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'); + + /// 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_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/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/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/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/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 new file mode 100644 index 000000000000..72d7fb85af14 --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/types.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. +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'; +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 new file mode 100644 index 000000000000..70194d9037d4 --- /dev/null +++ b/packages/camera/camera_web/pubspec.yaml @@ -0,0 +1,34 @@ +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 + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +flutter: + plugin: + implements: camera + platforms: + web: + pluginClass: CameraPlugin + fileName: camera_web.dart + +dependencies: + camera_platform_interface: ^2.1.0 + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + pedantic: ^1.11.1 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/more_tests_exist_elsewhere_test.dart b/packages/camera/camera_web/test/more_tests_exist_elsewhere_test.dart new file mode 100644 index 000000000000..dc2b64c111d7 --- /dev/null +++ b/packages/camera/camera_web/test/more_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 more tests', () { + print('---'); + print('This package also uses integration_test for its tests.'); + print('See `example/README.md` for more info.'); + print('---'); + }); +} diff --git a/packages/connectivity/connectivity/CHANGELOG.md b/packages/connectivity/connectivity/CHANGELOG.md index 89db7aeba9bb..f5489692bee9 100644 --- a/packages/connectivity/connectivity/CHANGELOG.md +++ b/packages/connectivity/connectivity/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +* Remove references to the Android V1 embedding. +* Updated Android lint settings. + ## 3.0.6 * Update README to point to Plus Plugins version. diff --git a/packages/connectivity/connectivity/android/build.gradle b/packages/connectivity/connectivity/android/build.gradle index afd7d9f0a977..983f29b142de 100644 --- a/packages/connectivity/connectivity/android/build.gradle +++ b/packages/connectivity/connectivity/android/build.gradle @@ -35,5 +35,19 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + } + + + 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/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/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/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/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/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/device_info/device_info/CHANGELOG.md b/packages/device_info/device_info/CHANGELOG.md index a92cb8ce94b1..97349d450cf1 100644 --- a/packages/device_info/device_info/CHANGELOG.md +++ b/packages/device_info/device_info/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +* Remove references to the Android V1 embedding. +* Updated Android lint settings. + ## 2.0.2 * Update README to point to Plus Plugins version. diff --git a/packages/device_info/device_info/android/build.gradle b/packages/device_info/device_info/android/build.gradle index 9b1f6470a37d..ed89da419d4a 100644 --- a/packages/device_info/device_info/android/build.gradle +++ b/packages/device_info/device_info/android/build.gradle @@ -30,5 +30,19 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + } + + + 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/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/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/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/packages/espresso/CHANGELOG.md b/packages/espresso/CHANGELOG.md index 4699db18c579..e00ea7065ce0 100644 --- a/packages/espresso/CHANGELOG.md +++ b/packages/espresso/CHANGELOG.md @@ -1,3 +1,11 @@ +## NEXT + +* Updated Android lint settings. + +## 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/android/build.gradle b/packages/espresso/android/build.gradle index 74988a50a3b9..da0cd2ebfee8 100644 --- a/packages/espresso/android/build.gradle +++ b/packages/espresso/android/build.gradle @@ -30,6 +30,21 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") + } + + + 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/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/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/file_selector/file_selector_web/CHANGELOG.md b/packages/file_selector/file_selector_web/CHANGELOG.md index 3eb7c3b94494..e2a863643027 100644 --- a/packages/file_selector/file_selector_web/CHANGELOG.md +++ b/packages/file_selector/file_selector_web/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.8.1+2 + +* Add `implements` to pubspec. + +# 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/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/file_selector/file_selector_web/pubspec.yaml b/packages/file_selector/file_selector_web/pubspec.yaml index ebbdfdbbd4da..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 +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/flutter_plugin_android_lifecycle/CHANGELOG.md b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md index f24a22332eaa..7e567d8cce5c 100644 --- a/packages/flutter_plugin_android_lifecycle/CHANGELOG.md +++ b/packages/flutter_plugin_android_lifecycle/CHANGELOG.md @@ -1,9 +1,18 @@ +## NEXT + +* Updated Android lint settings. + +## 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/android/build.gradle b/packages/flutter_plugin_android_lifecycle/android/build.gradle index cf34c98aaf3b..5a584b4e366f 100644 --- a/packages/flutter_plugin_android_lifecycle/android/build.gradle +++ b/packages/flutter_plugin_android_lifecycle/android/build.gradle @@ -31,11 +31,25 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } 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/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/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/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/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..3080d4a2d733 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,7 +1,12 @@ -## NEXT +## 2.0.8 + +* Mark iOS arm64 simulators as unsupported. + +## 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/android/build.gradle b/packages/google_maps_flutter/google_maps_flutter/android/build.gradle index 1fabe10216c3..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 { @@ -38,15 +39,26 @@ 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 { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } -} -dependencies { - testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-core:3.2.4' + 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/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/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/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/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/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..43ddeaae1579 --- /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,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. + +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.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 = + 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 89% 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 index fccd4c95c3ac..244a22b6c6c8 100644 --- 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 @@ -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_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/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/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_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/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_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 0d7475857b31..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.6 +version: 2.0.8 environment: sdk: '>=2.12.0 <3.0.0' 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(''); + } +} 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..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,11 @@ +## 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. + ## 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/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_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml index c69b8e55fa1c..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+2 +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/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index cb4a65f42fa2..8ac07ae1793b 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,6 +1,19 @@ ## NEXT +* Updated Android lint settings. + +## 5.0.7 + +* Mark iOS arm64 simulators as unsupported. + +## 5.0.6 + +* Remove references to the Android V1 embedding. + +## 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/android/build.gradle b/packages/google_sign_in/google_sign_in/android/build.gradle index c95ba17c10d7..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,20 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + } + + + 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/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/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/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/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 89% 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 index 36787ffd9910..edc01de491af 100644 --- 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 @@ -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/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/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/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/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/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/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/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/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..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 @@ -12,12 +12,15 @@ 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 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 a57f2197576d..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.4 +version: 5.0.7 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..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,11 @@ +## 0.10.0+2 + +* Add `implements` to pubspec. + +## 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/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/google_sign_in/google_sign_in_web/pubspec.yaml b/packages/google_sign_in/google_sign_in_web/pubspec.yaml index 44020fe598c3..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 +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/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 0e49912b4ed4..5dc260993773 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,39 @@ +## 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. +* Updated Android lint settings. + +## 0.8.3+2 + +* Fix using Camera as image source on Android 11+ + +## 0.8.3+1 + +* Fixed README Example. + +## 0.8.3 + +* Move `ImagePickerFromLimitedGalleryUITests` to `RunnerUITests` target. +* Improved handling of bad image data when applying metadata changes on iOS. + +## 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. +* Refactor unit tests that were device-only before. + ## 0.8.1+3 * Fix image picker causing a crash when the cache directory is deleted. @@ -36,8 +72,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/README.md b/packages/image_picker/image_picker/README.md index 3b3746d9f63e..7499c356f3aa 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,17 @@ 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(); ... ``` @@ -46,9 +56,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 +78,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/android/build.gradle b/packages/image_picker/image_picker/android/build.gradle index e21b7f1738b4..1e6439e6a4eb 100755 --- a/packages/image_picker/image_picker/android/build.gradle +++ b/packages/image_picker/image_picker/android/build.gradle @@ -30,10 +30,33 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { 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 + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } } } 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 8b904f5d769d..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 @@ -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; @@ -229,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); @@ -291,13 +283,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 +290,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 +367,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 +374,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() { @@ -562,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)); @@ -571,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); } } @@ -619,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); @@ -628,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/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 90% 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 index 1b55a7569eac..d2ee7b0b7d61 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/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -5,9 +5,13 @@ 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; import static org.mockito.Mockito.verify; @@ -16,6 +20,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; @@ -23,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; @@ -42,7 +51,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 +172,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 +185,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 +198,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 +212,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 +237,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 +252,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( @@ -370,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, @@ -379,7 +411,6 @@ private ImagePickerDelegate createDelegate() { null, cache, mockPermissionManager, - mockIntentResolver, mockFileUriResolver, mockFileUtils); } @@ -393,7 +424,6 @@ private ImagePickerDelegate createDelegateWithPendingResultAndMethodCall() { mockMethodCall, cache, mockPermissionManager, - mockIntentResolver, mockFileUriResolver, mockFileUtils); } 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 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/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/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 89% 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 index 1ca37ce5feb7..91e068fa8043 100644 --- 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 @@ -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/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/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/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/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..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 = NHAKRD9N7D; - 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/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/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/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 { diff --git a/packages/image_picker/image_picker/example/lib/main.dart b/packages/image_picker/image_picker/example/lib/main.dart index 71388ef5db2f..0f5ba76db6df 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; } @@ -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/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/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/ios/Classes/FLTImagePickerPlugin.m b/packages/image_picker/image_picker/ios/Classes/FLTImagePickerPlugin.m index 7c91606ba535..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; @@ -37,7 +38,6 @@ @interface FLTImagePickerPlugin () *)registrar { @@ -70,6 +70,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]; @@ -78,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; @@ -95,13 +111,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 +200,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]; @@ -362,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"]; @@ -406,8 +429,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 +551,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/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 bcda757b4bbf..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.1+3 +version: 0.8.4 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.3.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..10bc64082aca 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,11 +310,29 @@ 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'); }); + 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 { @@ -323,7 +341,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 +351,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 +363,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 +379,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 +445,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 +458,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); }); }); }); 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..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,17 @@ +## 2.1.3 + +* Add `implements` to pubspec. + +## 2.1.2 + +* Updated installation instructions in README. + +# 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/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/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..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.0 +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/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..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 @@ -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,19 @@ class MethodChannelImagePicker extends ImagePickerPlatform { final String? path = result['path']; + final pathList = result['pathList']; + if (pathList != null) { + pickedFileList = []; + 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 { diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 52bbff52bef0..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,11 @@ +## 1.0.8 + +* Fix repository link in pubspec.yaml. + +## 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/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/pubspec.yaml b/packages/in_app_purchase/in_app_purchase/pubspec.yaml index 554a07b0bd30..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.6 +version: 1.0.8 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..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,20 @@ +## 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. +* Updated Android lint settings. + +## 0.1.4+4 + +* Removed dependency on the `test` package. + +## 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..dcf5256e3bbc 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,14 @@ -# 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][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. -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 - ... -``` - -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 +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 \ 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/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle index eeac168068f7..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,11 +30,25 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } compileOptions { 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/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/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/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_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 41136e7501f6..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+2 +version: 0.1.4+6 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 @@ -22,10 +23,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_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); }); }); 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..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,15 @@ +## 0.1.3+3 + +* Add `implements` to pubspec. + +# 0.1.3+2 + +* Removed dependency on the `test` package. + +# 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..fcd4834e9cdc 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,14 @@ -# 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][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. -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 - ... -``` - -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 +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 \ 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..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 +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 @@ -21,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/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); 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)'); + }); +} 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/local_auth/CHANGELOG.md b/packages/local_auth/CHANGELOG.md index a97c4b47b288..c0d04fb5688a 100644 --- a/packages/local_auth/CHANGELOG.md +++ b/packages/local_auth/CHANGELOG.md @@ -1,3 +1,11 @@ +## NEXT + +* Updated Android lint settings. + +## 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/android/build.gradle b/packages/local_auth/android/build.gradle index 4b0995e65946..dc282e78ced0 100644 --- a/packages/local_auth/android/build.gradle +++ b/packages/local_auth/android/build.gradle @@ -30,6 +30,21 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") + } + + + 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/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/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/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/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/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..0fe91175cf6b 100644 --- a/packages/package_info/CHANGELOG.md +++ b/packages/package_info/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +* Remove references to the Android v1 embedding. +* Updated Android lint settings. + ## 2.0.2 * Update README to point to Plus Plugins version. diff --git a/packages/package_info/android/build.gradle b/packages/package_info/android/build.gradle index 9144e6aade58..e21d911ff490 100644 --- a/packages/package_info/android/build.gradle +++ b/packages/package_info/android/build.gradle @@ -30,5 +30,19 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + } + + + 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/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/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/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/package_info/example/android/app/src/main/AndroidManifest.xml b/packages/package_info/example/android/app/src/main/AndroidManifest.xml index e4d033e8d8dd..efb42ac02c5c 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 @@ - + - - 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")); - } -} diff --git a/packages/path_provider/path_provider/CHANGELOG.md b/packages/path_provider/path_provider/CHANGELOG.md index ca05c24eedb7..ba7bb3dc7ada 100644 --- a/packages/path_provider/path_provider/CHANGELOG.md +++ b/packages/path_provider/path_provider/CHANGELOG.md @@ -1,6 +1,11 @@ ## NEXT +* Updated Android lint settings. + +## 2.0.3 + * Add iOS unit test target. +* Remove references to the Android V1 embedding. ## 2.0.2 diff --git a/packages/path_provider/path_provider/android/build.gradle b/packages/path_provider/path_provider/android/build.gradle index 6df60f0a3a63..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 { @@ -37,6 +38,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/path_provider/path_provider/example/android/app/src/androidTest/java/EmbeddingV1ActivityTest.java b/packages/path_provider/path_provider/example/android/app/src/androidTest/java/EmbeddingV1ActivityTest.java deleted file mode 100644 index b6a39a8260ce..000000000000 --- a/packages/path_provider/path_provider/example/android/app/src/androidTest/java/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.pathprovider; - -import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; -import io.flutter.plugins.pathproviderexample.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/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/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/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/quick_actions/quick_actions/CHANGELOG.md b/packages/quick_actions/quick_actions/CHANGELOG.md index b917dcc85db0..d893b67b10dc 100644 --- a/packages/quick_actions/quick_actions/CHANGELOG.md +++ b/packages/quick_actions/quick_actions/CHANGELOG.md @@ -1,3 +1,16 @@ +## 0.6.0+6 + +* Updated Android lint settings. +* Fix repository link in pubspec.yaml. + +## 0.6.0+5 + +* Support only calling initialize once. + +## 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/android/build.gradle b/packages/quick_actions/quick_actions/android/build.gradle index 00de9453f86d..ec3f84eab4cf 100644 --- a/packages/quick_actions/quick_actions/android/build.gradle +++ b/packages/quick_actions/quick_actions/android/build.gradle @@ -30,5 +30,28 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + } + + 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 + unitTests.returnDefaultValues = true + unitTests.all { + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } } } 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 new file mode 100644 index 000000000000..208a119efafe --- /dev/null +++ b/packages/quick_actions/quick_actions/android/src/test/java/io/flutter/plugins/quickactions/QuickActionsTest.java @@ -0,0 +1,165 @@ +// Copyright 2013 The Flutter 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 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 { + 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. + } + } + + 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(); + final FlutterPluginBinding mockPluginBinding = mock(FlutterPluginBinding.class); + when(mockPluginBinding.getBinaryMessenger()).thenReturn(testBinaryMessenger); + + 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/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/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/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 89% 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 index 0b60dfa53e1f..e96548da291a 100644 --- 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 @@ -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/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/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/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/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 2a4fb0c634e0..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+3 +version: 0.6.0+6 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."); } diff --git a/packages/sensors/CHANGELOG.md b/packages/sensors/CHANGELOG.md index d7bf66d432a6..acea470855fb 100644 --- a/packages/sensors/CHANGELOG.md +++ b/packages/sensors/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +* Remove references to the Android V1 embedding. +* Updated Android lint settings. + ## 2.0.3 * Update README to point to Plus Plugins version. diff --git a/packages/sensors/android/build.gradle b/packages/sensors/android/build.gradle index 50b4eac981e2..7e1087764dee 100644 --- a/packages/sensors/android/build.gradle +++ b/packages/sensors/android/build.gradle @@ -30,5 +30,19 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + } + + + 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/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/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/CHANGELOG.md b/packages/share/CHANGELOG.md index a5e45110ebeb..c9a468d925a7 100644 --- a/packages/share/CHANGELOG.md +++ b/packages/share/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +* Remove references to the Android V1 embedding. +* Updated Android lint settings. + ## 2.0.4 * Update README to point to Plus Plugins version. diff --git a/packages/share/android/build.gradle b/packages/share/android/build.gradle index 231aaa653f2b..b2ea363a3e11 100644 --- a/packages/share/android/build.gradle +++ b/packages/share/android/build.gradle @@ -30,10 +30,24 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } dependencies { 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/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/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/shared_preferences/shared_preferences/CHANGELOG.md b/packages/shared_preferences/shared_preferences/CHANGELOG.md index 3476f4eff3f0..57b35a81255b 100644 --- a/packages/shared_preferences/shared_preferences/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences/CHANGELOG.md @@ -1,6 +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/build.gradle b/packages/shared_preferences/shared_preferences/android/build.gradle index 9f7eeca84512..9284f1c36143 100644 --- a/packages/shared_preferences/shared_preferences/android/build.gradle +++ b/packages/shared_preferences/shared_preferences/android/build.gradle @@ -38,9 +38,24 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + baseline file("lint-baseline.xml") } dependencies { 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/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/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" 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 ec08267fe59f..dd68f5321541 100644 --- a/packages/shared_preferences/shared_preferences_web/CHANGELOG.md +++ b/packages/shared_preferences/shared_preferences_web/CHANGELOG.md @@ -1,3 +1,12 @@ +## 2.0.2 + +* Add `implements` to pubspec. + +## 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 * Migrate to null-safety. 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/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/pubspec.yaml b/packages/shared_preferences/shared_preferences_web/pubspec.yaml index cd2e063fe6b3..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.0 +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/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/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/CHANGELOG.md b/packages/url_launcher/url_launcher/CHANGELOG.md index 1dcf7a1582a8..237f0b139475 100644 --- a/packages/url_launcher/url_launcher/CHANGELOG.md +++ b/packages/url_launcher/url_launcher/CHANGELOG.md @@ -1,3 +1,11 @@ +## NEXT + +* Updated Android lint settings. + +## 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/android/build.gradle b/packages/url_launcher/url_launcher/android/build.gradle index a0225af4491b..d374d40534c3 100644 --- a/packages/url_launcher/url_launcher/android/build.gradle +++ b/packages/url_launcher/url_launcher/android/build.gradle @@ -30,9 +30,20 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' } + + 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/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/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/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/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/url_launcher/url_launcher_linux/CHANGELOG.md b/packages/url_launcher/url_launcher_linux/CHANGELOG.md index ec9fad53437c..147d0f312c7e 100644 --- a/packages/url_launcher/url_launcher_linux/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_linux/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.0.2 + +* Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + +## 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..1d0667860030 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 `url_launcher` +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/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/packages/url_launcher/url_launcher_linux/pubspec.yaml b/packages/url_launcher/url_launcher_linux/pubspec.yaml index a5d6ddd24ff4..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.0 +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 976f7719329b..96d2fd49c7e7 100644 --- a/packages/url_launcher/url_launcher_macos/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_macos/CHANGELOG.md @@ -1,6 +1,11 @@ -## NEXT +## 2.0.2 + +* Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + +## 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..0869f0ce9940 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 `url_launcher` +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..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.0 +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 488c3387cb68..f5338e62a775 100644 --- a/packages/url_launcher/url_launcher_web/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_web/CHANGELOG.md @@ -1,3 +1,15 @@ +## 2.0.4 + +* Add `implements` to pubspec. + +## 2.0.3 + +- Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + +## 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..8043c9fa07ff 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 `url_launcher` +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/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/url_launcher/url_launcher_web/pubspec.yaml b/packages/url_launcher/url_launcher_web/pubspec.yaml index 7afdc0af85e2..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.1 +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/url_launcher/url_launcher_windows/CHANGELOG.md b/packages/url_launcher/url_launcher_windows/CHANGELOG.md index e906254eef44..d095a52341b5 100644 --- a/packages/url_launcher/url_launcher_windows/CHANGELOG.md +++ b/packages/url_launcher/url_launcher_windows/CHANGELOG.md @@ -1,3 +1,15 @@ +## NEXT + +* Added unit tests. + +## 2.0.2 + +* Replaced reference to `shared_preferences` plugin with the `url_launcher` in the README. + +## 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..cd7b6d47eeb2 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 `url_launcher` +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/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 1a82f3e94a43..a92e91ee4568 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.2 environment: sdk: ">=2.12.0 <3.0.0" @@ -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)); +} diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index b9f029b31454..f07bb5f66f8c 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,23 @@ +## NEXT + +* Updated Android lint settings. + +## 2.1.14 + +* Removed dependency on the `flutter_test` package. + +## 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 + +## 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/README.md b/packages/video_player/video_player/README.md index 7140527afb9f..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 @@ -75,7 +73,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 +127,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/android/build.gradle b/packages/video_player/video_player/android/build.gradle index d0ee30375376..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 { @@ -51,4 +52,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/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" @@ -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 diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index 38bfe90f7b1e..a7a198db21e1 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.0.3 + +* Add `implements` to pubspec. + +## 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/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 diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index 568a9262b5f0..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.1 +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/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md index f43812d438f8..361bfd24f3af 100644 --- a/packages/webview_flutter/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -1,3 +1,21 @@ +## NEXT + +* Updated Android lint settings. + +## 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 + +* Remove references to the Android V1 embedding. + +## 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/webview_flutter/README.md b/packages/webview_flutter/webview_flutter/README.md deleted file mode 100644 index 2bfc312d36ab..000000000000 --- a/packages/webview_flutter/webview_flutter/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# WebView for Flutter - -[![pub package](https://img.shields.io/pub/v/webview_flutter.svg)](https://pub.dev/packages/webview_flutter) - -A Flutter plugin that provides a WebView widget. - -On iOS the WebView widget is backed by a [WKWebView](https://developer.apple.com/documentation/webkit/wkwebview); -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). - -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) -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](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). - -### Using Hybrid Composition - -1. Set the `minSdkVersion` in `android/app/build.gradle`: - -```groovy -android { - defaultConfig { - minSdkVersion 19 - } -} -``` - -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'; - -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', - ); - } -} -``` - -#### 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). diff --git a/packages/webview_flutter/webview_flutter/android/build.gradle b/packages/webview_flutter/webview_flutter/android/build.gradle index 45f769b4bc59..4a164317c60f 100644 --- a/packages/webview_flutter/webview_flutter/android/build.gradle +++ b/packages/webview_flutter/webview_flutter/android/build.gradle @@ -31,11 +31,27 @@ android { 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/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/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/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); + } +} 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/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/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 89% 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 index b18308ab2feb..a32aaebb0ecd 100644 --- 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 @@ -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/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/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/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 02f270fb9c49..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 @@ -1,15 +1,10 @@ - + android:label="webview_flutter_example"> @@ -39,4 +34,9 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java b/packages/webview_flutter/webview_flutter/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/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/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart index 4b9fecaee8a1..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 @@ -18,7 +18,8 @@ import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('initalUrl', (WidgetTester tester) async { + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('initialUrl', (WidgetTester tester) async { final Completer 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,8 +101,9 @@ void main() { final String content = await controller .evaluateJavascript('document.documentElement.innerText'); 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(); @@ -146,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 = ''' @@ -274,6 +278,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 +328,7 @@ void main() { final String customUserAgent2 = await _getUserAgent(controller); expect(customUserAgent2, defaultPlatformUserAgent); - }); + }, skip: Platform.isAndroid); group('Video playback policy', () { late String videoTestBase64; @@ -532,6 +537,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 +587,7 @@ void main() { String fullScreen = await controller.evaluateJavascript('isFullScreen();'); expect(fullScreen, _webviewBool(true)); - }); + }, skip: Platform.isAndroid); }); group('Audio playback policy', () { @@ -796,6 +802,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 +877,7 @@ void main() { scrollPosY = await controller.getScrollY(); expect(scrollPosX, X_SCROLL * 2); expect(scrollPosY, Y_SCROLL * 2); - }); + }, skip: Platform.isAndroid); }); group('SurfaceAndroidWebView', () { @@ -882,6 +889,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 +956,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 +1062,7 @@ void main() { lastInputClientRectRelativeToViewport['right'] <= viewportRectRelativeToViewport['right'], isTrue); - }, skip: !Platform.isAndroid); + }, skip: true); }); group('NavigationDelegate', () { @@ -1130,6 +1139,7 @@ void main() { (WidgetTester tester) async { final Completer errorCompleter = Completer(); + final Completer pageFinishCompleter = Completer(); await tester.pumpWidget( Directionality( @@ -1141,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(); @@ -1272,18 +1325,22 @@ 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/'); + }, + // 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( '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 +1358,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/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 6acee01924a6..cc5d9cdc8b96 100644 --- a/packages/webview_flutter/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -1,8 +1,8 @@ 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.9 +version: 2.0.12 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 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 8a80e0ce8b6e..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,5 +29,19 @@ android { } lintOptions { disable 'InvalidPackage' + disable 'GradleDependency' + } + + + 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/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/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..2b0f844de7e0 --- /dev/null +++ b/script/configs/custom_analysis.yaml @@ -0,0 +1,46 @@ +# 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. +# +# 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 +- 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_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/configs/exclude_integration_android.yaml b/script/configs/exclude_integration_android.yaml new file mode 100644 index 000000000000..d8bd10b3a36e --- /dev/null +++ b/script/configs/exclude_integration_android.yaml @@ -0,0 +1,18 @@ +# Currently missing harness files: https://github.com/flutter/flutter/issues/86749) +- camera/camera +- in_app_purchase/in_app_purchase +- in_app_purchase_android +- shared_preferences/shared_preferences +- url_launcher/url_launcher +- video_player/video_player + +# 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: +- 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..6c0fc4efcb7a --- /dev/null +++ b/script/configs/exclude_integration_web.yaml @@ -0,0 +1,2 @@ +# 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 3f31a4953f6b..098e57a8c62d 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,4 +1,63 @@ -## 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 + +- Fixed `build-examples` to work for non-plugin packages. + +## 0.6.0 + +- 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. +- `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 + `--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 + +- `--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 + `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. +- 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 + +- Improved `license-check` output. +- Use `java -version` rather than `java --version`, for compatibility with more + versions of Java. + +## 0.4.0 - Modified the output format of many commands - **Breaking change**: `firebase-test-lab` no longer supports `*_e2e.dart` @@ -10,6 +69,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/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/analyze_command.dart b/script/tool/lib/src/analyze_command.dart index e56b95d88eb0..faad7f4736eb 100644 --- a/script/tool/lib/src/analyze_command.dart +++ b/script/tool/lib/src/analyze_command.dart @@ -6,10 +6,13 @@ 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'; +import 'common/plugin_command.dart'; import 'common/process_runner.dart'; +import 'common/repository_package.dart'; const int _exitPackagesGetFailed = 3; @@ -23,7 +26,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 +43,8 @@ class AnalyzeCommand extends PackageLoopingCommand { late String _dartBinaryPath; + Set _allowedCustomAnalysisDirectories = const {}; + @override final String name = 'analyze'; @@ -48,15 +56,16 @@ 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') { continue; } - final bool allowed = (getStringListArg(_customAnalysisFlag)).any( + final bool allowed = _allowedCustomAnalysisDirectories.any( (String directory) => directory.isNotEmpty && path.isWithin( @@ -78,7 +87,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 entry) => entry.package.directory) + .toList(); final Set packagePaths = packageDirectories.map((Directory dir) => dir.path).toSet(); packageDirectories.removeWhere((Directory directory) { @@ -107,6 +119,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 = @@ -114,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..56c2f5c7dc87 100644 --- a/script/tool/lib/src/build_examples_command.dart +++ b/script/tool/lib/src/build_examples_command.dart @@ -11,11 +11,21 @@ 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'; -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 { @@ -29,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( @@ -45,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, ), }; @@ -96,41 +114,68 @@ class BuildExamplesCommand extends PackageLoopingCommand { } @override - Future runForPackage(Directory package) async { + 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)) { - 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(''); - for (final Directory example in getExamplesForPlugin(package)) { + bool builtSomething = false; + 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) { + // 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})'; @@ -143,18 +188,43 @@ 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); } Future _buildExample( - Directory example, + RepositoryPackage example, String flutterBuildType, { List extraBuildFlags = const [], }) 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, [ @@ -164,8 +234,15 @@ class BuildExamplesCommand extends PackageLoopingCommand { if (enableExperiment.isNotEmpty) '--enable-experiment=$enableExperiment', ], - workingDir: example, + workingDir: example.directory, ); + + if (temporaryPlatformDirectory != null && + temporaryPlatformDirectory.existsSync()) { + print('Cleaning up ${temporaryPlatformDirectory.path}'); + temporaryPlatformDirectory.deleteSync(recursive: true); + } + return exitCode == 0; } } @@ -175,6 +252,7 @@ class _PlatformDetails { const _PlatformDetails( this.label, { required this.pluginPlatform, + this.pluginPlatformVariant, required this.flutterBuildType, this.extraBuildFlags = const [], }); @@ -185,9 +263,18 @@ 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; + /// 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/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/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/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/package_looping_command.dart b/script/tool/lib/src/common/package_looping_command.dart index 9f4039ec7074..973ac9995cb8 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 { @@ -22,6 +23,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 +40,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,26 +78,39 @@ 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? _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 /// 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 /// 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. @@ -129,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. @@ -147,31 +171,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 = components[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 @@ -211,27 +217,42 @@ abstract class PackageLoopingCommand extends PluginCommand { Future _runInternal() async { _packagesWithWarnings.clear(); _otherWarningCount = 0; - _currentPackage = null; + _currentPackageEntry = null; await initializeRun(); - final List packages = includeSubpackages - ? await getPackages().toList() - : await getPlugins().toList(); + final List targetPackages = + await getPackagesToProcess().toList(); + + final Map results = + {}; + for (final PackageEnumerationEntry entry in targetPackages) { + _currentPackageEntry = entry; + _printPackageHeading(entry); - final Map results = {}; - for (final Directory 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 (entry.excluded) { + results[entry] = PackageResult.exclude(); + continue; + } + + 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}'; captureOutput ? print(message) : print(Colorize(message)..darkGray()); } - results[package] = result; + results[entry] = result; } - _currentPackage = null; + _currentPackageEntry = null; completeRun(); @@ -239,13 +260,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!'); @@ -266,8 +287,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 entry) { + final String packageDisplayName = entry.package.displayName; + String heading = entry.excluded + ? 'Not running for $packageDisplayName; excluded' + : 'Running for $packageDisplayName'; if (hasLongOutput) { heading = ''' @@ -275,24 +299,34 @@ abstract class PackageLoopingCommand extends PluginCommand { || $heading ============================================================ '''; - } else { + } else if (!entry.excluded) { heading = '$heading...'; } - captureOutput ? print(heading) : print(Colorize(heading)..cyan()); + if (captureOutput) { + print(heading); + } else { + final Colorize colorizeHeading = Colorize(heading); + print( + entry.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,18 +352,22 @@ 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 packageEnumeration, + {required Set skipped}) { print('Run overview:'); - for (final Directory 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 (skipped.contains(package)) { + if (entry.excluded) { + summary = 'excluded'; + style = Styles.DARK_GRAY; + } else if (skipped.contains(entry)) { summary = 'skipped'; style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY; } else { - summary = 'ran'; + summary = successSummaryMessage; style = hadWarning ? Styles.YELLOW : Styles.GREEN; } if (hadWarning) { @@ -339,18 +377,18 @@ abstract class PackageLoopingCommand extends PluginCommand { if (!captureOutput) { summary = (Colorize(summary)..apply(style)).toString(); } - print(' ${getPackageDescription(package)} - $summary'); + print(' ${entry.package.displayName} - $summary'); } print(''); } /// Prints a summary of all of the failures from [results]. - void _printFailureSummary( - List packages, Map results) { + void _printFailureSummary(List packageEnumeration, + Map results) { const String indentation = ' '; _printError(failureListHeader); - for (final Directory 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 = ''; @@ -358,8 +396,7 @@ abstract class PackageLoopingCommand extends PluginCommand { errorDetails = ':\n$errorIndentation${result.details.join('\n$errorIndentation')}'; } - _printError( - '$indentation${getPackageDescription(package)}$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 ecdcb0565d35..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'; @@ -9,10 +10,27 @@ 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'; 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. + PackageEnumerationEntry(this.package, {required this.excluded}); + + /// 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; +} /// Interface definition for all commands in this tool. // TODO(stuartmorgan): Move most of this logic to PackageLoopingCommand. @@ -48,16 +66,25 @@ 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, 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' @@ -70,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. @@ -94,6 +122,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; @@ -171,56 +202,123 @@ 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 getExcludedPackageNames() { + 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.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); 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* { + 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 Set excludedPlugins = - Set.from(getStringListArg(_excludeArg)); - final bool runOnChangedPackages = getBoolArg(_runOnChangedPackagesArg); - if (plugins.isEmpty && - runOnChangedPackages && - !(await _changesRequireFullTest())) { - plugins = await _getChangedPackages(); + + 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(); + + if (runOnChangedPackages) { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final List changedFiles = + await gitVersionFinder.getChangedFiles(); + if (!_changesRequireFullTest(changedFiles)) { + plugins = _getChangedPackages(changedFiles); + } } final Directory thirdPartyPackagesDirectory = packagesDir.parent @@ -235,9 +333,10 @@ 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( + RepositoryPackage(entity as Directory), + excluded: excludedPluginNames.contains(entity.basename)); } } else if (entity is Directory) { // Look for Dart packages under this top-level directory. @@ -251,13 +350,14 @@ 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( + RepositoryPackage(subdir as Directory), + excluded: excludedPluginNames.contains(basenamePath) || + excludedPluginNames.contains(packageName) || + excludedPluginNames.contains(relativePath)); } } } @@ -266,33 +366,36 @@ 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.package.directory .list(recursive: true, followLinks: false) .where(_isDartPackage) - .cast(); + .map((FileSystemEntity directory) => PackageEnumerationEntry( + // _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 getPlugins() - .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(); @@ -304,25 +407,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. @@ -334,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) { @@ -358,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. @@ -377,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/lib/src/common/plugin_utils.dart b/script/tool/lib/src/common/plugin_utils.dart index 0277b78d566a..6cfe9928d689 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,12 @@ enum PlatformSupport { federated, } -/// Returns whether the given directory contains a Flutter [platform] plugin. +/// 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: /// @@ -27,84 +33,114 @@ 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, - {PlatformSupport? requiredMode}) { +bool pluginSupportsPlatform( + String platform, + RepositoryPackage plugin, { + PlatformSupport? requiredMode, + String? variant, +}) { assert(platform == kPlatformIos || platform == kPlatformAndroid || platform == kPlatformWeb || platform == kPlatformMacos || platform == kPlatformWindows || platform == kPlatformLinux); - if (entity is! Directory) { + + final YamlMap? platformEntry = + _readPlatformPubspecSectionForPlugin(platform, plugin); + if (platformEntry == null) { 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) { + // 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; } - 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) { + } + + // 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 (!pluginSection.containsKey('platforms')) { - return platform == kPlatformIos || platform == kPlatformAndroid; + } 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 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(kPlatformAndroid, entity); -} -/// Returns whether the given directory contains a Flutter iOS plugin. -bool isIosPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformIos, entity); + return true; } -/// 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 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; + } + 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 whether the given directory contains a Flutter macOS plugin. -bool isMacOsPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformMacos, entity); +/// 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) { + 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 whether the given directory contains a Flutter linux plugin. -bool isLinuxPlugin(FileSystemEntity entity) { - return pluginSupportsPlatform(kPlatformLinux, entity); +/// 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; + } + 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/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/common/xcode.dart b/script/tool/lib/src/common/xcode.dart new file mode 100644 index 000000000000..83f681bcb492 --- /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 commands 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/create_all_plugins_app_command.dart b/script/tool/lib/src/create_all_plugins_app_command.dart index ed7014456086..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,9 @@ 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'; /// A command to create an application that builds all in a single application. class CreateAllPluginsAppCommand extends PluginCommand { @@ -18,16 +21,19 @@ class CreateAllPluginsAppCommand extends PluginCommand { 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 +49,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(), @@ -156,13 +171,15 @@ 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 entry in getTargetPackages()) { + final RepositoryPackage package = entry.package; + final Directory pluginDirectory = package.directory; + final String pluginName = pluginDirectory.basename; + final File pubspecFile = package.pubspecFile; 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/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 7e800ed54866..b3434b0659f3 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; @@ -35,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: '', @@ -66,6 +70,7 @@ class DriveExamplesCommand extends PackageLoopingCommand { kPlatformMacos, kPlatformWeb, kPlatformWindows, + kPlatformWinUwp, ]; final int platformCount = platformSwitches .where((String platform) => getBoolArg(platform)) @@ -80,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'); @@ -115,13 +124,17 @@ 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'], }; } @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( @@ -131,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}...'); @@ -140,16 +163,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 +196,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 +208,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 +253,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 +277,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 +302,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 +320,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 5e4d9f080085..4fc47c0da70c 100644 --- a/script/tool/lib/src/firebase_test_lab_command.dart +++ b/script/tool/lib/src/firebase_test_lab_command.dart @@ -10,8 +10,10 @@ 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'; +import 'common/repository_package.dart'; const int _exitGcloudAuthFailed = 2; @@ -74,15 +76,12 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { 'Runs tests in test_instrumentation folder using the ' 'instrumentation_test package.'; - 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,39 +109,44 @@ 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') + Future runForPackage(RepositoryPackage package) async { + final RepositoryPackage example = package.getSingleExampleDeprecated(); + final Directory androidDirectory = + example.directory.childDirectory('android'); + if (!androidDirectory.existsSync()) { + return PackageResult.skip( + '${example.displayName} 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)) { + final GradleProject project = GradleProject(example.directory, + 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']); } @@ -152,10 +156,10 @@ 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(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; @@ -163,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', @@ -176,7 +180,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', ]; @@ -184,24 +188,30 @@ 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'); 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); } - /// 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( @@ -211,7 +221,7 @@ class FirebaseTestLabCommand extends PackageLoopingCommand { 'apk', if (experiment.isNotEmpty) '--enable-experiment=$experiment', ], - workingDir: androidDirectory); + workingDir: project.androidDirectory); if (exitCode != 0) { return false; @@ -220,15 +230,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 { @@ -237,17 +247,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; @@ -256,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/format_command.dart b/script/tool/lib/src/format_command.dart index 7954fd044ce4..f24a99436c87 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,30 @@ class FormatCommand extends PluginCommand { return RegExp('(?:^|$s)${path.joinAll(directories)}$s'); } + 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 - .map((File file) => file.path) + .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) // to avoid useless extra work in local repositories. @@ -212,4 +261,77 @@ class FormatCommand extends PluginCommand { return javaFormatterPath; } + + /// 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, [versionFlag]); + 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/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/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/lib/src/lint_android_command.dart b/script/tool/lib/src/lint_android_command.dart new file mode 100644 index 000000000000..a7b5c4f2e8bf --- /dev/null +++ b/script/tool/lib/src/lint_android_command.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: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'; +import 'common/repository_package.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(RepositoryPackage package) async { + if (!pluginSupportsPlatform(kPlatformAndroid, package, + requiredMode: PlatformSupport.inline)) { + return PackageResult.skip( + 'Plugin does not have an Android implemenatation.'); + } + + 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.directory.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/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 20f01ff98f0e..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,18 +40,22 @@ 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 entry in getTargetPackages()) { + print(entry.package.path); } break; case _example: - await for (final Directory package in getExamples()) { + 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 Directory package in getPackages()) { - print(package.path); + await for (final PackageEnumerationEntry entry + in getTargetPackagesAndSubpackages()) { + print(entry.package.path); } break; case _file: diff --git a/script/tool/lib/src/main.dart b/script/tool/lib/src/main.dart index f397a04aa663..e70cba24cc5e 100644 --- a/script/tool/lib/src/main.dart +++ b/script/tool/lib/src/main.dart @@ -15,16 +15,17 @@ 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_android_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 'xctest_command.dart'; +import 'xcode_analyze_command.dart'; void main(List args) { const FileSystem fileSystem = LocalFileSystem(); @@ -50,16 +51,17 @@ void main(List args) { ..addCommand(DriveExamplesCommand(packagesDir)) ..addCommand(FirebaseTestLabCommand(packagesDir)) ..addCommand(FormatCommand(packagesDir)) - ..addCommand(JavaTestCommand(packagesDir)) ..addCommand(LicenseCheckCommand(packagesDir)) + ..addCommand(LintAndroidCommand(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(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..e50878db7906 --- /dev/null +++ b/script/tool/lib/src/native_test_command.dart @@ -0,0 +1,556 @@ +// Copyright 2013 The Flutter 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/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'; +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) +/// - Android: JUnit tests +/// - Windows and Linux: GoogleTest tests +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(kPlatformLinux, help: 'Runs Linux 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. + argParser.addFlag(_unitTestFlag, + help: 'Runs native unit tests', defaultsTo: true); + argParser.addFlag(_integrationTestFlag, + help: 'Runs native integration (UI) tests', defaultsTo: true); + } + + // 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 +- 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. +'''; + + Map _platforms = {}; + + List _requestedPlatforms = []; + + @override + Future initializeRun() async { + _platforms = { + kPlatformAndroid: _PlatformDetails('Android', _testAndroid), + kPlatformIos: _PlatformDetails('iOS', _testIos), + kPlatformLinux: _PlatformDetails('Linux', _testLinux), + kPlatformMacos: _PlatformDetails('macOS', _testMacOS), + kPlatformWindows: _PlatformDetails('Windows', _testWindows), + }; + _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(kPlatformWindows) && getBoolArg(_integrationTestFlag)) { + logWarning('This command currently only supports unit tests for Windows. ' + '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); + 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(RepositoryPackage package) async { + final List testPlatforms = []; + for (final String platform in _requestedPlatforms) { + if (!pluginSupportsPlatform(platform, package, + requiredMode: PlatformSupport.inline)) { + 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('Nothing to test 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( + RepositoryPackage plugin, _TestMode mode) async { + bool exampleHasUnitTests(RepositoryPackage example) { + return example.directory + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('test') + .existsSync() || + example.directory.parent + .childDirectory('android') + .childDirectory('src') + .childDirectory('test') + .existsSync(); + } + + bool exampleHasNativeIntegrationTests(RepositoryPackage example) { + final Directory integrationTestDirectory = example.directory + .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 = plugin.getExamples(); + + bool ranTests = false; + bool failed = false; + bool hasMissingBuild = false; + for (final RepositoryPackage example in examples) { + final bool hasUnitTests = exampleHasUnitTests(example); + 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'); + } + 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 = example.displayName; + _printRunningExampleTestsMessage(example, 'Android'); + + final GradleProject project = GradleProject( + example.directory, + 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.'); + failed = true; + hasMissingBuild = true; + continue; + } + + if (runUnitTests) { + print('Running unit tests...'); + final int exitCode = await project.runCommand('testDebugUnitTest'); + 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 project.runCommand( + 'app:connectedAndroidTest', + arguments: [ + '-Pandroid.testInstrumentationRunnerArguments.$filter', + ], + ); + 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(RunState.succeeded); + } + + Future<_PlatformResult> _testIos(RepositoryPackage plugin, _TestMode mode) { + return _runXcodeTests(plugin, 'iOS', mode, + extraFlags: _iosDestinationFlags); + } + + Future<_PlatformResult> _testMacOS(RepositoryPackage 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( + RepositoryPackage 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 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'); + 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.directory, + 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); + } + + 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); + } + + 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. + /// + /// 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 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('release') || components.contains('Release'); + })); + } + + 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( + 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(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( + RepositoryPackage, _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/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..6da51706ef1e 100644 --- a/script/tool/lib/src/publish_plugin_command.dart +++ b/script/tool/lib/src/publish_plugin_command.dart @@ -11,15 +11,19 @@ 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'; 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 { @@ -43,53 +47,34 @@ 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, { 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) { - 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.', - ); + super(packagesDir, + platform: platform, processRunner: processRunner, gitDir: gitDir) { 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', ); 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( @@ -103,15 +88,11 @@ 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'; @@ -129,160 +110,117 @@ 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.'; - final Print _print; final io.Stdin _stdin; StreamSubscription? _stdinSubscription; final PubVersionFinder _pubVersionFinder; + // Tags that already exist in the repository. + List _existingGitTags = []; + // The remote to push tags to. + late _RemoteInfo _remote; + @override - Future run() async { - final String package = getStringArg(_packageOption); - final bool publishAllChanged = getBoolArg(_allChangedFlag); - if (package.isEmpty && !publishAllChanged) { - _print( - 'Must specify a package to publish. See `plugin_tools help publish-plugin`.'); + String get successSummaryMessage => 'published'; + + @override + String get failureListHeader => + 'The following packages had failures during publishing:'; + + @override + Future initializeRun() async { + print('Checking local repo...'); + + // 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); } + _remote = _RemoteInfo(name: remoteName, url: remoteUrl); - _print('Checking local repo...'); + // 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); - 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); - } - _print('Local repo is ready!'); if (getBoolArg(_dryRunFlag)) { - _print('=============== DRY RUN ==============='); + print('=============== DRY RUN ==============='); } + } - bool successful; - if (publishAllChanged) { - successful = await _publishAllChangedPackages( - baseGitDir: repository, - remoteForTagPush: remote, - ); + @override + Stream getPackagesToProcess() async* { + if (getBoolArg(_allChangedFlag)) { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final List changedPubspecs = + await gitVersionFinder.getChangedPubSpecs(); + + for (final String pubspecPath in changedPubspecs) { + // 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); + } } else { - successful = await _publishAndTagPackage( - packageDir: _getPackageDir(package), - remoteForTagPush: remote, - ); + yield* getTargetPackages(filterExcluded: false); } - - _pubVersionFinder.httpClient.close(); - await _finish(successful); } - Future _publishAllChangedPackages({ - required GitDir baseGitDir, - _RemoteInfo? remoteForTagPush, - }) async { - final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); - final List changedPubspecs = - await gitVersionFinder.getChangedPubSpecs(); - if (changedPubspecs.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; } - _print('Getting existing tags...'); - 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 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); - final _CheckNeedsReleaseResult result = await _checkNeedsRelease( - pubspecFile: pubspecFile, - existingTags: existingTags, - ); - switch (result) { - case _CheckNeedsReleaseResult.release: - break; - case _CheckNeedsReleaseResult.noRelease: - continue; - case _CheckNeedsReleaseResult.failure: - packagesFailed.add(pubspecFile.parent.basename); - continue; - } - _print('\n'); - if (await _publishAndTagPackage( - packageDir: pubspecFile.parent, - remoteForTagPush: remoteForTagPush, - )) { - packagesReleased.add(pubspecFile.parent.basename); - } else { - packagesFailed.add(pubspecFile.parent.basename); - } - _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) { - _print( - '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`. - // If `_tagReleaseOption` is on, git tag the release. - // 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, - _RemoteInfo? remoteForTagPush, - }) async { - if (!await _publishPlugin(packageDir: packageDir)) { - return false; - } - if (getBoolArg(_tagReleaseOption)) { - if (!await _tagRelease( - packageDir: packageDir, - remoteForPush: remoteForTagPush, - )) { - return false; - } - } - _print('Released [${packageDir.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 File pubspecFile, - 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(''' -The file at The pubspec file at ${pubspecFile.path} does not exist. Publishing will not happen for ${pubspecFile.parent.basename}. + 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()); @@ -291,120 +229,81 @@ 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) { - _print( + 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 published. + // Check if the package named `packageName` with `version` has already + // been 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( + 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) { - _print( - '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; + printError( + '${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; + return null; } - // Publish the plugin. - // - // Returns `true` if successful, `false` otherwise. - Future _publishPlugin({required Directory packageDir}) async { - final bool gitStatusOK = await _checkGitStatus(packageDir); - if (!gitStatusOK) { - return false; - } - final bool publishOK = await _publish(packageDir); - if (!publishOK) { - return false; - } - _print('Package published!'); - return true; - } - - // 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({ - required Directory packageDir, - _RemoteInfo? remoteForPush, - }) async { - final String tag = _getTag(packageDir); - _print('Tagging release $tag...'); + Future _tagRelease(RepositoryPackage package) async { + final String tag = _getTag(package); + 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; } } - 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 { - await _stdinSubscription?.cancel(); - _stdinSubscription = null; - if (successful) { - _print('Done!'); - } else { - _print('Failed, see above for details.'); - throw ToolExit(1); - } - } - - // 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); - if (!packageDir.existsSync()) { - _print('${packageDir.absolute.path} does not exist.'); - throw ToolExit(1); + if (success) { + print('Release tagged!'); } - return packageDir; + return success; } - Future _checkGitStatus(Directory packageDir) async { - final io.ProcessResult statusResult = await processRunner.run( - 'git', - ['status', '--porcelain', '--ignored', packageDir.absolute.path], - workingDir: packageDir, - logOnError: true, + Future _checkGitStatus(RepositoryPackage package) async { + final io.ProcessResult statusResult = await (await gitDir).runCommand( + [ + 'status', + '--porcelain', + '--ignored', + package.directory.absolute.path + ], + throwOnError: false, ); if (statusResult.exitCode != 0) { return false; @@ -412,7 +311,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.'); @@ -421,11 +320,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; @@ -433,10 +330,11 @@ 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 { + print('Publishing...'); 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; } @@ -450,26 +348,24 @@ 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)); + 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 .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('Publishing ${package.directory.basename} failed.'); return false; } + + print('Package published!'); 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; @@ -489,20 +385,10 @@ 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 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; @@ -570,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/lib/src/pubspec_check_command.dart b/script/tool/lib/src/pubspec_check_command.dart index 539b170dbea1..29f9ea733a03 100644 --- a/script/tool/lib/src/pubspec_check_command.dart +++ b/script/tool/lib/src/pubspec_check_command.dart @@ -7,8 +7,10 @@ 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'; +import 'common/repository_package.dart'; /// A command to enforce pubspec conventions across the repository. /// @@ -63,10 +65,10 @@ class PubspecCheckCommand extends PackageLoopingCommand { bool get includeSubpackages => true; @override - Future runForPackage(Directory package) async { - final File pubspec = package.childFile('pubspec.yaml'); - final bool passesCheck = !pubspec.existsSync() || - await _checkPubspec(pubspec, packageName: package.basename); + Future runForPackage(RepositoryPackage package) async { + final File pubspec = package.pubspecFile; + final bool passesCheck = + !pubspec.existsSync() || await _checkPubspec(pubspec, package: package); if (!passesCheck) { return PackageResult.fail(); } @@ -75,7 +77,7 @@ class PubspecCheckCommand extends PackageLoopingCommand { Future _checkPubspec( File pubspecFile, { - required String packageName, + required RepositoryPackage package, }) async { final String contents = pubspecFile.readAsStringSync(); final Pubspec? pubspec = _tryParsePubspec(contents); @@ -84,29 +86,39 @@ 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 (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, packageName: packageName); + _checkForRepositoryLinkErrors(pubspec, package: package); 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'); @@ -144,14 +156,18 @@ class PubspecCheckCommand extends PackageLoopingCommand { List _checkForRepositoryLinkErrors( Pubspec pubspec, { - required String packageName, + required RepositoryPackage 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) { @@ -168,4 +184,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 RepositoryPackage package, + }) { + if (_isImplementationPackage(package)) { + final String? implements = + pubspec.flutter!['plugin']!['implements'] as String?; + final String expectedImplements = package.directory.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(RepositoryPackage package) { + // An implementation package should be in a group folder... + final Directory parentDir = package.directory.parent; + if (parentDir.path == packagesDir.path) { + 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; + } + // 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/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 c08600c3f669..6b49c40d66bb 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 { @@ -32,6 +33,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]. /// @@ -118,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. @@ -140,12 +156,29 @@ 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))) { - errors.add('pubspec.yaml and CHANGELOG.md have different versions'); + if (!(await _validateChangelogVersion(package, + pubspec: pubspec, pubspecVersionChanged: versionChanged))) { + errors.add('CHANGELOG.md failed validation.'); } return errors.isEmpty @@ -164,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; @@ -182,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. @@ -195,11 +228,10 @@ ${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( - Directory package, { + /// Returns the state of the verison of [package] relative to the comparison + /// base (git or pub, depending on flags). + Future<_CurrentVersionState> _getVersionState( + RepositoryPackage package, { required Pubspec pubspec, }) async { // This method isn't called unless `version` is non-null. @@ -208,7 +240,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 +257,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 +273,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 +289,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,22 +300,26 @@ ${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( - Directory package, { + /// 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( + RepositoryPackage package, { required Pubspec pubspec, + required bool pubspecVersionChanged, }) async { // This method isn't called unless `version` is non-null. 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; @@ -296,10 +332,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 +379,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; } } @@ -344,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 new file mode 100644 index 000000000000..3d34dab9f087 --- /dev/null +++ b/script/tool/lib/src/xcode_analyze_command.dart @@ -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. + +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/repository_package.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(RepositoryPackage 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( + RepositoryPackage plugin, + String platform, { + List extraFlags = const [], + }) async { + bool passing = true; + for (final RepositoryPackage example in plugin.getExamples()) { + // Running tests and static analyzer. + 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.directory, + 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 deleted file mode 100644 index 176adad39a09..000000000000 --- a/script/tool/lib/src/xctest_command.dart +++ /dev/null @@ -1,256 +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: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'; - -const String _kiOSDestination = 'ios-destination'; -const String _kXcodeBuildCommand = 'xcodebuild'; -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 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) { - argParser.addOption( - _kiOSDestination, - 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.addFlag(kPlatformIos, help: 'Runs the iOS tests'); - argParser.addFlag(kPlatformMacos, help: 'Runs the macOS tests'); - } - - // The device destination flags for iOS tests. - List _iosDestinationFlags = []; - - @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 - String get failureListHeader => 'The following packages are failing XCTests:'; - - @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(_kiOSDestination); - if (destination.isEmpty) { - final String? simulatorId = await _findAvailableIphoneSimulator(); - if (simulatorId == null) { - printError(_kFoundNoSimulatorsMessage); - 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 = []; - if (testIos && - !await _testPlugin(package, 'iOS', - extraXcrunFlags: _iosDestinationFlags)) { - failures.add('iOS'); - } - if (testMacos && !await _testPlugin(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 : []); - } - - /// Runs all applicable tests for [plugin], printing status and returning - /// success if the tests passed. - Future _testPlugin( - Directory plugin, - String platform, { - List extraXcrunFlags = 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...'); - 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) { - continue; - } - id = device['udid'] as String?; - if (id == null) { - continue; - } - print('device selected: $device'); - return id; - } - } - return null; - } -} diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml index 6273fe9bf277..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.3.0 +version: 0.7.0 dependencies: args: ^2.1.0 diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart index 69a2c4f95523..502fa9a0634c 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, @@ -192,7 +211,7 @@ void main() { createFakePlugin('foo', packagesDir); processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess.failing() // flutter packages get + MockProcess(exitCode: 1) // flutter packages get ]; Error? commandError; @@ -214,7 +233,7 @@ void main() { createFakePlugin('foo', packagesDir); processRunner.mockProcessesForExecutable['dart'] = [ - MockProcess.failing() // dart analyze + MockProcess(exitCode: 1) // dart analyze ]; Error? commandError; @@ -232,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, + ), + ]), + ); + }); } diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart index 27489a50228a..d9cbad246d28 100644 --- a/script/tool/test/build_examples_command_test.dart +++ b/script/tool/test/build_examples_command_test.dart @@ -56,14 +56,14 @@ void main() { test('fails if building fails', () async { createFakePlugin('plugin', packagesDir, - platformSupport: { - kPlatformIos: PlatformSupport.inline + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.inline), }); processRunner .mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = [ - MockProcess.failing() // flutter packages get + MockProcess(exitCode: 1) // flutter packages get ]; Error? commandError; @@ -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; @@ -106,8 +135,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 +192,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 +241,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 +287,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 +313,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 +325,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 +334,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 +350,7 @@ void main() { expect( output, containsAllInOrder([ - '\nBUILDING plugin/example for Windows', + '\nBUILDING plugin/example for Win32 (windows)', ]), ); @@ -335,6 +364,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 +472,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 +501,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 +523,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 = @@ -432,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/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/gradle_test.dart b/script/tool/test/common/gradle_test.dart new file mode 100644 index 000000000000..3eac60baf3c3 --- /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(exitCode: 1), + ]; + + final int exitCode = await project.runCommand('foo'); + + expect(exitCode, 1); + }); + }); +} diff --git a/script/tool/test/common/package_looping_command_test.dart b/script/tool/test/common/package_looping_command_test.dart index 542e91af6431..7cf03960a74d 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'; @@ -35,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; @@ -116,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); @@ -140,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', () { @@ -185,6 +213,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 +426,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 @@ -397,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); @@ -435,6 +527,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 +588,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); @@ -502,64 +631,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 { @@ -623,21 +694,25 @@ 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()); } + final File throwFile = package.directory.childFile(_throwFile); + if (throwFile.existsSync()) { + throw Exception('Uh-oh'); + } return PackageResult.success(); } diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart index fdab9612be3f..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'; @@ -22,14 +23,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; setUp(() { fileSystem = MemoryFileSystem(); @@ -39,23 +38,18 @@ 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(); - plugins = []; - final SamplePluginCommand samplePluginCommand = SamplePluginCommand( - plugins, + command = SamplePluginCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, @@ -63,7 +57,7 @@ void main() { ); runner = CommandRunner('common_command', 'Test for common functionality'); - runner.addCommand(samplePluginCommand); + runner.addCommand(command); }); group('plugin iteration', () { @@ -71,7 +65,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 +76,7 @@ void main() { final Directory package4 = createFakePackage('package4', packagesDir); await runCapturingPrint(runner, ['sample']); expect( - plugins, + command.plugins, unorderedEquals([ plugin1.path, plugin2.path, @@ -96,7 +91,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 +103,7 @@ void main() { await runCapturingPrint( runner, ['sample', '--packages=plugin1,package4']); expect( - plugins, + command.plugins, unorderedEquals([ plugin1.path, package4.path, @@ -123,7 +118,7 @@ void main() { await runCapturingPrint( runner, ['sample', '--plugins=plugin1,package4']); expect( - plugins, + command.plugins, unorderedEquals([ plugin1.path, package4.path, @@ -138,7 +133,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 +141,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 +152,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 +164,82 @@ 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 { + createFakePlugin('plugin1', packagesDir); + final File configFile = packagesDir.childFile('exclude.yaml'); + configFile.writeAsStringSync('- plugin1'); + + await runCapturingPrint(runner, [ + 'sample', + '--packages=plugin1', + '--exclude=${configFile.path}' + ]); + 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', () { @@ -182,13 +252,16 @@ 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 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, [ @@ -197,14 +270,17 @@ 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 { - 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, [ @@ -213,14 +289,17 @@ 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 { - 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, [ @@ -229,15 +308,18 @@ 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', () 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, [ @@ -246,15 +328,18 @@ 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.', () 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, [ @@ -263,15 +348,18 @@ 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.', () 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, [ @@ -280,15 +368,18 @@ 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.', () 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, [ @@ -297,11 +388,14 @@ 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 { - 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, [ @@ -310,15 +404,17 @@ 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', () 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, [ @@ -327,15 +423,17 @@ 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', () 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); @@ -345,17 +443,20 @@ 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( '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); @@ -366,37 +467,43 @@ packages/plugin1/plugin1_web/plugin1_web.dart '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path])); + expect(command.plugins, unorderedEquals([plugin1.path])); }); test( - '--packages flag overrides the behavior of --run-on-changed-packages', + 'changing one plugin in a federated group should include all plugins in the group', () async { - gitDiffResponse = ''' -packages/plugin1/plugin1.dart -packages/plugin2/ios/plugin2.m -packages/plugin3/plugin3.dart -'''; + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' +packages/plugin1/plugin1/plugin1.dart +'''), + ]; final Directory plugin1 = createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); - final Directory plugin2 = createFakePlugin('plugin2', packagesDir); - createFakePlugin('plugin3', packagesDir); + final Directory plugin2 = createFakePlugin('plugin1_platform_interface', + packagesDir.childDirectory('plugin1')); + final Directory plugin3 = createFakePlugin( + 'plugin1_web', packagesDir.childDirectory('plugin1')); await runCapturingPrint(runner, [ 'sample', - '--packages=plugin1,plugin2', '--base-sha=master', '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path, plugin2.path])); + expect( + command.plugins, + unorderedEquals( + [plugin1.path, plugin2.path, plugin3.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); @@ -408,15 +515,223 @@ packages/plugin3/plugin3.dart '--run-on-changed-packages' ]); - expect(plugins, unorderedEquals([plugin1.path])); + expect(command.plugins, unorderedEquals([plugin1.path])); + }); + }); + }); + + 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 = >[ + [ + 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 packageDir) => packageDir.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 packageDir) => packageDir.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 packageDir) => packageDir.path) + .toList())); + } }); }); } class SamplePluginCommand extends PluginCommand { SamplePluginCommand( - this._plugins, Directory packagesDir, { ProcessRunner processRunner = const ProcessRunner(), Platform platform = const LocalPlatform(), @@ -424,7 +739,7 @@ class SamplePluginCommand extends PluginCommand { }) : super(packagesDir, processRunner: processRunner, platform: platform, gitDir: gitDir); - final List _plugins; + final List plugins = []; @override final String name = 'sample'; @@ -434,10 +749,8 @@ class SamplePluginCommand extends PluginCommand { @override Future run() async { - await for (final Directory package in getPlugins()) { - _plugins.add(package.path); + await for (final PackageEnumerationEntry entry in getTargetPackages()) { + plugins.add(entry.package.path); } } } - -class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart index c32c3f8e02bf..ac619e2622e0 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,15 +34,16 @@ void main() { }); test('all platforms', () async { - 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, - }); + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + '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), isTrue); expect(pluginSupportsPlatform(kPlatformIos, plugin), isTrue); @@ -51,15 +54,13 @@ void main() { }); test('some platforms', () async { - final Directory plugin = createFakePlugin( - 'plugin', - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.inline, - kPlatformLinux: PlatformSupport.inline, - kPlatformWeb: PlatformSupport.inline, - }, - ); + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + '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); @@ -70,18 +71,16 @@ void main() { }); test('inline plugins are only detected as inline', () async { - 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, - }, - ); + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + '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, @@ -134,19 +133,16 @@ void main() { }); test('federated plugins are only detected as federated', () async { - const String pluginName = 'plugin'; - final Directory plugin = createFakePlugin( - pluginName, - packagesDir, - platformSupport: { - kPlatformAndroid: PlatformSupport.federated, - kPlatformIos: PlatformSupport.federated, - kPlatformLinux: PlatformSupport.federated, - kPlatformMacos: PlatformSupport.federated, - kPlatformWeb: PlatformSupport.federated, - kPlatformWindows: PlatformSupport.federated, - }, - ); + final RepositoryPackage plugin = RepositoryPackage(createFakePlugin( + '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, @@ -197,5 +193,152 @@ 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); + }); + }); + + 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/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/common/xcode_test.dart b/script/tool/test/common/xcode_test.dart new file mode 100644 index 000000000000..259d8ea36cd2 --- /dev/null +++ b/script/tool/test/common/xcode_test.dart @@ -0,0 +1,406 @@ +// Copyright 2013 The Flutter 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.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: 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.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(devices)), + ]; + + expect(await xcode.findBestAvailableIphoneSimulator(), null); + }); + + test('returns null if simctl fails', () async { + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1), + ]; + + 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(exitCode: 1), + ]; + 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 { + const String stdout = ''' +{ + "project" : { + "configurations" : [ + "Debug", + "Release" + ], + "name" : "Runner", + "schemes" : [ + "Runner" + ], + "targets" : [ + "Runner", + "RunnerTests", + "RunnerUITests" + ] + } +}'''; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: stdout), + ]; + + 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 { + const String stdout = ''' +{ + "project" : { + "configurations" : [ + "Debug", + "Release" + ], + "name" : "Runner", + "schemes" : [ + "Runner" + ], + "targets" : [ + "Runner", + "RunnerUITests" + ] + } +}'''; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: stdout), + ]; + + 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.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: '{}'), + ]; + + 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.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: ':)'), + ]; + + 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.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1), // xcodebuild -list + ]; + + 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/create_all_plugins_app_command_test.dart b/script/tool/test/create_all_plugins_app_command_test.dart index 073024a17bb3..0066cc53f61a 100644 --- a/script/tool/test/create_all_plugins_app_command_test.dart +++ b/script/tool/test/create_all_plugins_app_command_test.dart @@ -13,24 +13,23 @@ 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 // 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'); - 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/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index c6893181e286..85d2326d0689 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 { @@ -129,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), }, ); @@ -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( @@ -194,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), }, ); @@ -244,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), }, ); @@ -277,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), }, ); @@ -313,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), }, ); @@ -399,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), }, ); @@ -472,8 +470,8 @@ void main() { 'example/test_driver/plugin.dart', 'example/macos/macos.swift', ], - platformSupport: { - kPlatformMacos: PlatformSupport.inline, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), }, ); @@ -543,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), }, ); @@ -617,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), }, ); @@ -656,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', @@ -664,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), }, ); @@ -714,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), }, ); @@ -747,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), }, ); @@ -802,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), }, ); @@ -844,8 +876,8 @@ void main() { 'plugin', packagesDir, examples: [], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }, ); @@ -876,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), }, ); @@ -908,8 +940,8 @@ void main() { extraFiles: [ 'example/test_driver/integration_test.dart', ], - platformSupport: { - kPlatformWeb: PlatformSupport.inline, + platformSupport: { + kPlatformWeb: const PlatformDetails(PlatformSupport.inline), }, ); @@ -944,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), }, ); @@ -954,8 +986,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 c265868bbf3e..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', @@ -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 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( + '/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 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'), + ]), + ); + }); + test('runs integration tests', () async { createFakePlugin('plugin', packagesDir, extraFiles: [ 'test/plugin_test.dart', @@ -140,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( @@ -150,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'), ]), @@ -166,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; @@ -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, @@ -291,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'), ]), @@ -305,7 +459,7 @@ void main() { ]); processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess.failing() // flutter build + MockProcess(exitCode: 1) // flutter build ]; Error? commandError; @@ -342,7 +496,7 @@ void main() { .childFile('gradlew') .path; processRunner.mockProcessesForExecutable[gradlewPath] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; @@ -379,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; @@ -447,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'), ]), diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart index fabef31a1b64..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'; @@ -19,8 +20,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 +30,7 @@ void main() { mockPlatform = MockPlatform(); packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = RecordingProcessRunner(); - final FormatCommand analyzeCommand = FormatCommand( + analyzeCommand = FormatCommand( packagesDir, processRunner: processRunner, platform: mockPlatform, @@ -37,7 +38,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 +47,39 @@ void main() { runner.addCommand(analyzeCommand); }); - List _getAbsolutePaths( - Directory package, List relativePaths) { + /// Returns a modified version of a list of [relativePaths] that are relative + /// to [package] to instead be relative to [packagesDir]. + List _getPackagesDirRelativePaths( + Directory packageDir, List relativePaths) { + final p.Context path = analyzeCommand.path; + final String relativeBase = + path.relative(packageDir.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 +98,47 @@ void main() { processRunner.recordedCalls, orderedEquals([ ProcessCall( - 'flutter', - ['format', ..._getAbsolutePaths(pluginDir, files)], + getFlutterCommand(mockPlatform), + [ + 'format', + ..._getPackagesDirRelativePaths(pluginDir, files) + ], + packagesDir.path), + ])); + }); + + 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), ])); }); @@ -85,9 +151,8 @@ void main() { ]; createFakePlugin('a_plugin', packagesDir, extraFiles: files); - processRunner.mockProcessesForExecutable['flutter'] = [ - MockProcess.failing() - ]; + processRunner.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [MockProcess(exitCode: 1)]; Error? commandError; final List output = await runCapturingPrint( runner, ['format'], errorHandler: (Error e) { @@ -118,18 +183,45 @@ 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 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', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['java'] = [ + MockProcess(exitCode: 1) + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + 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', @@ -138,7 +230,8 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['java'] = [ - MockProcess.failing() + MockProcess(), // check for working java + MockProcess(exitCode: 1), // format ]; Error? commandError; final List output = await runCapturingPrint( @@ -154,6 +247,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,12 +296,69 @@ 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 with a clear message if clang-format is not in the path', + () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['clang-format'] = [ + MockProcess(exitCode: 1) + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + 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), ])); @@ -193,7 +372,8 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['clang-format'] = [ - MockProcess.failing() + MockProcess(), // check for working clang-format + MockProcess(exitCode: 1), // format ]; Error? commandError; final List output = await runCapturingPrint( @@ -246,12 +426,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 +442,7 @@ void main() { '-jar', javaFormatPath, '--replace', - ..._getAbsolutePaths(pluginDir, javaFiles) + ..._getPackagesDirRelativePaths(pluginDir, javaFiles) ], packagesDir.path), ])); @@ -272,11 +455,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'], @@ -302,7 +485,7 @@ void main() { createFakePlugin('a_plugin', packagesDir, extraFiles: files); processRunner.mockProcessesForExecutable['git'] = [ - MockProcess.failing() + MockProcess(exitCode: 1) ]; Error? commandError; final List output = @@ -326,12 +509,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'], @@ -348,4 +531,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), + )); + }); } 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/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'), + ])); }); }); } 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..5670a64f30d8 --- /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: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(PlatformSupport.inline) + }); + + processRunner.mockProcessesForExecutable['gradlew'] = [ + MockProcess(exitCode: 1), + ]; + + 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: const PlatformDetails(PlatformSupport.federated) + }); + + final List output = + await runCapturingPrint(runner, ['lint-android']); + + expect( + output, + containsAllInOrder( + [ + contains( + 'SKIPPING: Plugin does not have an Android implemenatation.') + ], + )); + }); + }); +} 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 new file mode 100644 index 000000000000..d1ab11f6e50d --- /dev/null +++ b/script/tool/test/native_test_command_test.dart @@ -0,0 +1,1634 @@ +// Copyright 2013 The Flutter 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/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'; + +import 'mocks.dart'; +import 'util.dart'; + +const String _androidIntegrationTestFilter = + '-Pandroid.testInstrumentationRunnerArguments.' + 'notAnnotation=io.flutter.plugins.DartIntegrationTest'; + +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 on Posix', () { + 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: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + // Exit code 66 from testing indicates no tests. + MockProcess(exitCode: 66), + ]; + 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: const PlatformDetails(PlatformSupport.inline), + }); + + final List output = await runCapturingPrint(runner, + ['native-test', '--ios', _kDestination, 'foo_destination']); + expect( + output, + containsAllInOrder([ + contains('No implementation for iOS.'), + contains('SKIPPING: Nothing to test for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if iOS is implemented in a federated package', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformIos: const PlatformDetails(PlatformSupport.federated) + }); + + final List output = await runCapturingPrint(runner, + ['native-test', '--ios', _kDestination, 'foo_destination']); + expect( + output, + containsAllInOrder([ + contains('No implementation for iOS.'), + contains('SKIPPING: Nothing to test for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('running with correct destination', () async { + final Directory pluginDirectory = createFakePlugin( + 'plugin', packagesDir, platformSupport: { + kPlatformIos: const PlatformDetails(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: const PlatformDetails(PlatformSupport.inline) + }); + final Directory pluginExampleDirectory = + pluginDirectory.childDirectory('example'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(_kDeviceListMap)), // simctl + ]; + + 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: Nothing to test for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('skip if macOS is implemented in a federated package', () async { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.federated), + }); + + final List output = + await runCapturingPrint(runner, ['native-test', '--macos']); + + expect( + output, + containsAllInOrder([ + contains('No implementation for macOS.'), + contains('SKIPPING: Nothing to test for target platform(s).'), + ])); + expect(processRunner.recordedCalls, orderedEquals([])); + }); + + test('runs for macOS plugin', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: const PlatformDetails(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 unit tests in Android implementation folder', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(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'], + androidFolder.path, + ), + ]), + ); + }); + + test('runs Java unit tests in example folder', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(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'], + androidFolder.path, + ), + ]), + ); + }); + + test('runs Java integration tests', () async { + final Directory plugin = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(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', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(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('logs missing test types', () async { + // No unit tests. + createFakePlugin( + 'plugin1', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + // No integration tests. + createFakePlugin( + 'plugin2', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(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', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(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(exitCode: 1) + ]; + + 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 unit 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: Nothing to test for target platform(s).'), + ]), + ); + }); + + test('skips when running no tests', () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) + }, + ); + + final List output = await runCapturingPrint( + runner, ['native-test', '--android']); + + 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.'), + ]), + ); + }); + }); + + 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 { + createFakePlugin('plugin', packagesDir, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + }); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1) + ]; + + 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: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + const Map projects = { + 'project': { + 'targets': ['RunnerTests', 'RunnerUITests'] + } + }; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + ]; + + 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: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + const Map projects = { + 'project': { + 'targets': ['RunnerTests', 'RunnerUITests'] + } + }; + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(stdout: jsonEncode(projects)), // xcodebuild -list + ]; + + 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: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + 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 + ]; + + // ... 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: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1), // xcodebuild -list + ]; + + 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: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(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'], 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: const PlatformDetails(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: const PlatformDetails(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', + '--windows', + _kDestination, + 'foo_destination', + ]); + + expect( + output, + containsAllInOrder([ + contains('No implementation for Android.'), + contains('No implementation for iOS.'), + contains('No implementation for macOS.'), + 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([])); + }); + + test('failing one platform does not stop the tests', () async { + final Directory pluginDir = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(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(exitCode: 1) + ]; + + 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 unit 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: const PlatformDetails(PlatformSupport.inline), + kPlatformIos: const PlatformDetails(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(exitCode: 1) + ]; + // Simulate failing Android. + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1) + ]; + + 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') + ]), + ); + }); + }); + }); + + 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/Release/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 release 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 releaseTestBinary = childFileWithSubcomponents( + pluginDirectory, + ['example', ...releaseTestBinaryRelativePath.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 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: { + 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/Release/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/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 11de9f095481..e1ab0e224e44 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,6 @@ void main() { final Directory plugin2Dir = createFakePlugin('plugin_tools_test_package_b', packagesDir); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); await runCapturingPrint(runner, ['publish-check']); expect( @@ -75,11 +68,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,9 +82,6 @@ void main() { final Directory dir = createFakePlugin('c', packagesDir); await dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); - final MockProcess process = MockProcess(); - processRunner.processesToReturn.add(process); - expect(() => runCapturingPrint(runner, ['publish-check']), throwsA(isA())); }); @@ -101,15 +89,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 +107,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 +123,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 +173,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,10 +236,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']); @@ -331,10 +305,6 @@ void main() { await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); - processRunner.processesToReturn.add( - MockProcess.succeeding(), - ); - bool hasError = false; final List output = await runCapturingPrint( runner, ['publish-check', '--machine'], @@ -369,10 +339,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..2ea4fc753460 100644 --- a/script/tool/test/publish_plugin_command_test.dart +++ b/script/tool/test/publish_plugin_command_test.dart @@ -8,35 +8,30 @@ 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 List printedMessages; + final String flutterCommand = getFlutterCommand(const LocalPlatform()); - late Directory testRoot; late Directory packagesDir; - late Directory pluginDir; - late GitDir gitDir; + late MockGitDir 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. - const FileSystem fileSystem = LocalFileSystem(); + late FileSystem fileSystem; + // Map of package name to mock response. + late Map> mockHttpResponses; void _createMockCredentialFile() { final String credentialPath = PublishPluginCommand.getCredentialPath(); @@ -46,421 +41,411 @@ 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); - 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']); + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); processRunner = TestProcessRunner(); - mockStdin = MockStdin(); - printedMessages = []; - commandRunner = CommandRunner('tester', '') - ..addCommand(PublishPluginCommand(packagesDir, - processRunner: processRunner, - print: (Object? message) => printedMessages.add(message.toString()), - stdinput: mockStdin, - gitDir: gitDir)); - }); - - tearDown(() { - testRoot.deleteSync(recursive: true); - }); - 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.')); + 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); }); - 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')); + 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 outer 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); }); + mockStdin = MockStdin(); + commandRunner = CommandRunner('tester', '') + ..addCommand(PublishPluginCommand( + packagesDir, + processRunner: processRunner, + stdinput: mockStdin, + gitDir: gitDir, + httpClient: mockClient, + )); + }); + + group('Initial validation', () { test('refuses to proceed with dirty files', () async { - pluginDir.childFile('tmp').createSync(); + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); - await expectLater( - () => commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags' - ]), - throwsA(isA())); + processRunner.mockProcessesForExecutable['git-status'] = [ + MockProcess(stdout: '?? ${pluginDir.childFile('tmp').path}\n') + ]; + + Error? commandError; + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--packages=foo', + ], 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('foo:\n' + ' uncommitted changes'), ])); }); test('fails immediately if the remote doesn\'t exist', () async { - await expectLater( - () => commandRunner - .run(['publish-plugin', '--package', testPluginName]), - throwsA(isA())); - expect(processRunner.results.last.stderr, contains('No such remote')); - }); + createFakePlugin('foo', packagesDir, examples: []); - test("doesn't validate the remote if it's not pushing tags", () async { - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; + processRunner.mockProcessesForExecutable['git-remote'] = [ + MockProcess(exitCode: 1), + ]; - await commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', - '--no-tag-release' - ]); - - expect(printedMessages.last, 'Done!'); - }); + Error? commandError; + final List output = await runCapturingPrint( + commandRunner, ['publish-plugin', '--packages=foo'], + errorHandler: (Error e) { + commandError = e; + }); - 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; - await commandRunner.run([ - 'publish-plugin', - '--package', - packageName, - '--no-push-tags', - '--no-tag-release' - ]); - expect(printedMessages.last, 'Done!'); + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Unable to find URL for remote upstream; cannot push tags'), + ])); }); }); group('Publishes package', () { test('while showing all output from pub publish to the user', () async { - final Future publishCommand = commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', - '--no-tag-release' - ]); - - processRunner.mockPublishStdout = 'Foo'; - processRunner.mockPublishStderr = 'Bar'; - processRunner.mockPublishCompleteCode = 0; - - await publishCommand; + createFakePlugin('plugin1', packagesDir, examples: []); + createFakePlugin('plugin2', packagesDir, examples: []); + + processRunner.mockProcessesForExecutable[flutterCommand] = [ + MockProcess( + stdout: 'Foo', + stderr: 'Bar', + stdoutEncoding: utf8, + 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', '--packages=plugin1,plugin2']); - expect(printedMessages, contains('Foo')); - expect(printedMessages, contains('Bar')); + 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!'), + ])); }); test('forwards input from the user to `pub publish`', () async { - final Future publishCommand = commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', - '--no-tag-release' - ]); + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.mockUserInputs.add(utf8.encode('user input')); - processRunner.mockPublishCompleteCode = 0; - await publishCommand; + await runCapturingPrint( + commandRunner, ['publish-plugin', '--packages=foo']); expect(processRunner.mockPublishProcess.stdinMock.lines, contains('user input')); }); test('forwards --pub-publish-flags to pub publish', () async { - processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); + + await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', - '--no-tag-release', + '--packages=foo', '--pub-publish-flags', - '--dry-run,--server=foo' + '--dry-run,--server=bar' ]); - 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=bar'], + pluginDir.path))); }); test( '--skip-confirmation flag automatically adds --force to --pub-publish-flags', () async { - processRunner.mockPublishCompleteCode = 0; _createMockCredentialFile(); - await commandRunner.run([ + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); + + await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', - '--no-tag-release', + '--packages=foo', '--skip-confirmation', '--pub-publish-flags', - '--server=foo' + '--server=bar' ]); - 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=bar', '--force'], + pluginDir.path))); }); 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.')); + createFakePlugin('foo', packagesDir, examples: []); + + processRunner.mockProcessesForExecutable[flutterCommand] = [ + MockProcess(exitCode: 128) // pub publish + ]; + + Error? commandError; + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--packages=foo', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Publishing 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 Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - testPluginName, + '--packages=foo', '--dry-run', - '--no-push-tags', - '--no-tag-release', ]); - expect(processRunner.pushTagsArgs, isEmpty); expect( - printedMessages, - containsAllInOrder([ - '=============== DRY RUN ===============', - 'Running `pub publish ` in ${pluginDir.path}...\n', - 'Done!' + processRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); + expect( + output, + 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!'), ])); }); + + test('can publish non-flutter package', () async { + const String packageName = 'a_package'; + createFakePackage(packageName, packagesDir); + + final List output = + await runCapturingPrint(commandRunner, [ + 'publish-plugin', + '--packages=$packageName', + ]); + + expect( + output, + containsAllInOrder( + [ + contains('Running `pub publish ` in /packages/a_package...'), + contains('Package published!'), + ], + ), + ); + }); }); group('Tags release', () { test('with the version and name from the pubspec.yaml', () async { - processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ + createFakePlugin('foo', packagesDir, examples: []); + await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - testPluginName, - '--no-push-tags', + '--packages=foo', ]); - final String? tag = (await gitDir - .runCommand(['show-ref', '$testPluginName-v0.0.1'])) - .stdout as String?; - expect(tag, isNotEmpty); + expect(processRunner.recordedCalls, + contains(const ProcessCall('git-tag', ['foo-v0.0.1'], null))); }); 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.')); - final String? tag = (await gitDir.runCommand( - ['show-ref', '$testPluginName-v0.0.1'], - throwOnError: false)) - .stdout as String?; - expect(tag, isEmpty); - }); - }); + createFakePlugin('foo', packagesDir, examples: []); - group('Pushes tags', () { - setUp(() async { - await gitDir.runCommand( - ['remote', 'add', 'upstream', 'http://localhost:8000']); - }); + processRunner.mockProcessesForExecutable[flutterCommand] = [ + MockProcess(exitCode: 128) // pub publish + ]; - 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', + '--packages=foo', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Publishing foo failed.'), + ])); + expect( + processRunner.recordedCalls, + isNot(contains( + const ProcessCall('git-tag', ['foo-v0.0.1'], null)))); }); + }); + group('Pushes tags', () { test('to upstream by default', () async { - await gitDir.runCommand(['tag', 'garbage']); - processRunner.mockPublishCompleteCode = 0; + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.readLineOutput = 'y'; - await commandRunner.run([ + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - testPluginName, + '--packages=foo', ]); - expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(printedMessages.last, 'Done!'); + expect( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'foo-v0.0.1'], null))); + expect( + output, + containsAllInOrder([ + contains('Pushing tag to upstream...'), + contains('Published foo 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(); - await commandRunner.run([ + createFakePlugin('foo', packagesDir, examples: []); + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', '--skip-confirmation', - '--package', - testPluginName, + '--packages=foo', ]); - expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); - expect(processRunner.pushTagsArgs[1], 'upstream'); - expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(printedMessages.last, 'Done!'); + expect( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'foo-v0.0.1'], null))); + expect( + output, + containsAllInOrder([ + contains('Published foo 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; + final Directory pluginDir = + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.readLineOutput = 'y'; - await commandRunner.run( - ['publish-plugin', '--package', testPluginName, '--dry-run']); - expect(processRunner.pushTagsArgs, isEmpty); + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--packages=foo', '--dry-run']); + expect( - printedMessages, - containsAllInOrder([ - '=============== DRY RUN ===============', - 'Running `pub publish ` in ${pluginDir.path}...\n', - 'Tagging release $testPluginName-v0.0.1...', - 'Pushing tag to upstream...', - 'Done!' + processRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); + expect( + output, + 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!'), ])); }); test('to different remotes based on a flag', () async { - await gitDir.runCommand( - ['remote', 'add', 'origin', 'http://localhost:8001']); - processRunner.mockPublishCompleteCode = 0; + createFakePlugin('foo', packagesDir, examples: []); + mockStdin.readLineOutput = 'y'; - await commandRunner.run([ + + final List output = + await runCapturingPrint(commandRunner, [ 'publish-plugin', - '--package', - testPluginName, + '--packages=foo', '--remote', 'origin', ]); - expect(processRunner.pushTagsArgs.isNotEmpty, isTrue); - expect(processRunner.pushTagsArgs[1], 'origin'); - expect(processRunner.pushTagsArgs[2], '$testPluginName-v0.0.1'); - expect(printedMessages.last, 'Done!'); - }); - - test('only if tagging and pushing to remotes are both enabled', () async { - processRunner.mockPublishCompleteCode = 0; - await commandRunner.run([ - 'publish-plugin', - '--package', - testPluginName, - '--no-tag-release', - ]); - - expect(processRunner.pushTagsArgs.isEmpty, isTrue); - expect(printedMessages.last, 'Done!'); + expect( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['origin', 'foo-v0.0.1'], null))); + expect( + output, + containsAllInOrder([ + contains('Published foo 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 = { + 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, - 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 @@ -468,499 +453,332 @@ void main() { 'plugin2', packagesDir.childDirectory('plugin2'), ); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add plugins']); - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; 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, - 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!' + output, + 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.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( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin1-v0.0.1'], null))); + expect( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin2-v0.0.1'], null))); }); 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, - 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 + // 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']); - - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; - mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['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']); - // Immediately return 0 when running `pub publish`. - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + // Git results for plugin0 having been released already, and plugin1 and + // plugin2 being new. + processRunner.mockProcessesForExecutable['git-tag'] = [ + MockProcess(stdout: 'plugin0-v0.0.1\n') + ]; + processRunner.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(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( - printedMessages, + 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'); + expect( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin1-v0.0.1'], null))); + expect( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin2-v0.0.1'], null))); }); 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, - 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 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; + + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; mockStdin.readLineOutput = 'y'; - await commandRunner.run([ + + final List output = await runCapturingPrint( + commandRunner, [ 'publish-plugin', '--all-changed', '--base-sha=HEAD~', '--dry-run' ]); + expect( - printedMessages, - 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', - 'Done!' + output, + 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.pushTagsArgs, isEmpty); + expect( + processRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); test('version change triggers releases.', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', - 'versions': [], + 'versions': ['0.0.1'], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', - 'versions': [], + '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, - 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); + 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']); - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; + final Directory pluginDir2 = createFakePlugin( + 'plugin2', packagesDir.childDirectory('plugin2'), + version: '0.0.2'); + + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; + mockStdin.readLineOutput = 'y'; - 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, - 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!' + output2, + 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.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(); - printedMessages.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']); - - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( - printedMessages, - 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.2'); - expect(processRunner.pushTagsArgs[3], 'push'); - expect(processRunner.pushTagsArgs[4], 'upstream'); - expect(processRunner.pushTagsArgs[5], 'plugin2-v0.0.2'); + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin1-v0.0.2'], null))); + expect( + processRunner.recordedCalls, + contains(const ProcessCall( + 'git-push', ['upstream', 'plugin2-v0.0.2'], null))); }); test( - 'delete package will not trigger publish but exit the command successfully.', + 'delete package will not trigger publish but exit the command successfully!', () async { - const Map httpResponsePlugin1 = { + mockHttpResponses['plugin1'] = { 'name': 'plugin1', - 'versions': [], + 'versions': ['0.0.1'], }; - const Map httpResponsePlugin2 = { + mockHttpResponses['plugin2'] = { 'name': 'plugin2', - 'versions': [], + '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, - 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); + 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']); - // 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!', - '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(); - printedMessages.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' - ]); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + mockStdin.readLineOutput = 'y'; + + final List output2 = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); expect( - printedMessages, - containsAllInOrder([ - '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', - 'Packages released: plugin1', - 'Done!' + output2, + 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.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( + processRunner.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 = { + 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, - 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 - 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'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; + processRunner.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~']); + expect( - printedMessages, - 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!' + output, + 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(processRunner.pushTagsArgs, isEmpty); + expect( + processRunner.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 = { + 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, - 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 - 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'; - await expectLater( - () => commandRunner.run( - ['publish-plugin', '--all-changed', '--base-sha=HEAD~']), - throwsA(isA())); - expect(processRunner.pushTagsArgs, isEmpty); + + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('pubspec.yaml').path}\n' + '${pluginDir2.childFile('pubspec.yaml').path}\n') + ]; + + Error? commandError; + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + 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 + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); test('No version change does not release any plugins', () async { @@ -970,161 +788,84 @@ 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']); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess( + stdout: '${pluginDir1.childFile('plugin1.dart').path}\n' + '${pluginDir2.childFile('plugin2.dart').path}\n') + ]; - pluginDir1.childFile('plugin1.dart').createSync(); - pluginDir2.childFile('plugin2.dart').createSync(); - await gitDir.runCommand(['add', '-A']); - await gitDir.runCommand(['commit', '-m', 'Add dart files']); + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); - // Immediately return 0 when running `pub publish`. - processRunner.mockPublishCompleteCode = 0; - mockStdin.readLineOutput = 'y'; - await commandRunner - .run(['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect(output, containsAllInOrder(['Ran for 0 package(s)'])); expect( - printedMessages, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'No version updates in this commit.', - 'Done!' - ])); - expect(processRunner.pushTagsArgs, isEmpty); + processRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); 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, - 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~']); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: flutterPluginTools.childFile('pubspec.yaml').path) + ]; + + final List output = await runCapturingPrint(commandRunner, + ['publish-plugin', '--all-changed', '--base-sha=HEAD~']); + expect( - printedMessages, - containsAllInOrder([ - 'Checking local repo...', - 'Local repo is ready!', - 'Done!' + output, + containsAllInOrder([ + contains( + 'SKIPPING: publishing flutter_plugin_tools via the tool is not supported') ])); expect( - printedMessages.contains( - 'Running `pub publish ` in ${flutterPluginTools.path}...\n', + output.contains( + 'Running `pub publish ` in ${flutterPluginTools.path}...', ), isFalse); - expect(processRunner.pushTagsArgs, isEmpty); - processRunner.pushTagsArgs.clear(); - printedMessages.clear(); + expect( + processRunner.recordedCalls + .map((ProcessCall call) => call.executable), + isNot(contains('git-push'))); }); }); } -class TestProcessRunner extends ProcessRunner { - final List results = []; +/// 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 = []; - final MockProcessResult mockPushTagsResult = MockProcessResult(); - final List pushTagsArgs = []; - - String? mockPublishStdout; - String? mockPublishStderr; - int? mockPublishCompleteCode; - - @override - Future run( - String executable, - List args, { - Directory? workingDir, - bool exitOnError = false, - bool logOnError = false, - 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); - } - 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(); - if (mockPublishStdout != null) { - mockPublishProcess.stdoutController.add(utf8.encode(mockPublishStdout!)); - } - if (mockPublishStderr != null) { - mockPublishProcess.stderrController.add(utf8.encode(mockPublishStderr!)); + args[1] == 'publish') { + mockPublishProcess = process as MockProcess; } - if (mockPublishCompleteCode != null) { - mockPublishProcess.exitCodeCompleter.complete(mockPublishCompleteCode); - } - - return mockPublishProcess; + 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); } @@ -1144,12 +885,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/pubspec_check_command_test.dart b/script/tool/test/pubspec_check_command_test.dart index 177ed7f25b4e..c5d36013c40b 100644 --- a/script/tool/test/pubspec_check_command_test.dart +++ b/script/tool/test/pubspec_check_command_test.dart @@ -37,15 +37,30 @@ 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, + bool publishable = 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'; @@ -55,6 +70,7 @@ ${includeRepository ? 'repository: $repoLink' : ''} ${includeHomepage ? 'homepage: $repoLink' : ''} ${includeIssueTracker ? 'issue_tracker: $issueTrackerLink' : ''} version: 1.0.0 +${publishable ? '' : 'publish_to: \'none\''} '''; } @@ -66,9 +82,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 +197,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 +224,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 +250,45 @@ ${dependenciesSection()} ${devDependenciesSection()} '''); - 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( + 'Found a "homepage" entry; only "repository" should be used.'), + ]), + ); + }); + + 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; + }); - await expectLater( - result, - throwsA(isA()), + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('The "repository" link should end with the package path.'), + ]), ); }); @@ -237,12 +303,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 +329,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 +356,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 +383,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 +410,225 @@ ${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:'), + ]), + ); + }); + + 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; + }); - await expectLater( - result, - throwsA(isA()), + 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, + repositoryPackagesDirRelativePath: 'plugin_a/plugin_a_foo', + )} +${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, + repositoryPackagesDirRelativePath: 'plugin_a/plugin_a', + )} +${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, + repositoryPackagesDirRelativePath: + 'plugin_a/plugin_a_platform_interface', + )} +${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!'), + ]), + ); + }); + + 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!'), + ]), ); }); }); diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart index 503e24d03056..f8aca38d3478 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; @@ -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 1984a25cc430..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'; @@ -17,6 +18,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) => @@ -39,6 +42,33 @@ Directory createPackagesDirectory( return packagesDir; } +/// Details for platform support in a plugin. +@immutable +class PlatformDetails { + const PlatformDetails( + this.type, { + this.variants = const [], + this.hasNativeCode = true, + this.hasDartCode = false, + }); + + /// The type of support for the platform. + final PlatformSupport type; + + /// 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]. /// /// [platformSupport] is a map of platform string to the support details for @@ -46,13 +76,14 @@ 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, { List examples = const ['example'], List extraFiles = const [], - Map platformSupport = - const {}, + Map platformSupport = + const {}, String? version = '0.0.1', }) { final Directory pluginDirectory = createFakePackage(name, parentDirectory, @@ -77,6 +108,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, { @@ -88,7 +120,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. @@ -110,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; @@ -139,8 +167,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, }) { @@ -156,12 +184,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: @@ -182,50 +209,62 @@ 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 '''; + } else { + final List lines = [ + ' $platform:', + ]; + switch (platform) { + case kPlatformAndroid: + lines.add(' package: io.flutter.plugins.fake'); + continue nativeByDefault; + nativeByDefault: + case kPlatformIos: + case kPlatformLinux: + case kPlatformMacos: + 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: + lines.addAll([ + ' pluginClass: FakePlugin', + ' fileName: ${packageName}_web.dart', + ]); + break; + default: + assert(false, 'Unrecognized platform: $platform'); + break; + } + entry = lines.join('\n') + '\n'; } - switch (platform) { - case kPlatformAndroid: - return ''' - android: - package: io.flutter.plugins.fake - pluginClass: FakePlugin -'''; - case kPlatformIos: - return ''' - ios: - pluginClass: FLTFakePlugin -'''; - case kPlatformLinux: - return ''' - linux: - pluginClass: FakePlugin -'''; - case kPlatformMacos: - return ''' - macos: - pluginClass: FakePlugin -'''; - case kPlatformWeb: - return ''' - web: - pluginClass: FakePlugin - fileName: ${packageName}_web.dart + + // Add any variants. + if (support.variants.isNotEmpty) { + entry += ''' + supportedVariants: '''; - case kPlatformWindows: - return ''' - windows: - pluginClass: FakePlugin + for (final String variant in support.variants) { + entry += ''' + - $variant '''; - default: - assert(false); - return ''; + } } + + return entry; } typedef _ErrorHandler = void Function(Error error); @@ -265,15 +304,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 +321,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 +335,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); @@ -322,17 +357,16 @@ 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) { - 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/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart index 587de1a58cd9..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'); @@ -373,6 +389,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 +404,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 +438,47 @@ 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.'), + contains('plugin:\n' + ' CHANGELOG.md failed validation.'), + ]), + ); + }); + test('Fail if the version changes without replacing NEXT', () async { final Directory pluginDirectory = createFakePlugin('plugin', packagesDir, version: '1.0.1'); @@ -430,6 +490,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,14 +508,14 @@ 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.') ]), ); }); test('allows valid against pub', () async { - const Map httpResponse = { + mockHttpResponse = { 'name': 'some_package', 'versions': [ '0.0.1', @@ -459,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 = { @@ -485,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 = { @@ -532,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 = { @@ -563,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 ''') ]), ); @@ -571,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 = { 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..10008ae33a11 --- /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: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(PlatformSupport.inline) + }); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1) + ]; + + 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: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(PlatformSupport.inline), + }); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + MockProcess(exitCode: 1) + ]; + + 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: const PlatformDetails(PlatformSupport.inline), + kPlatformMacos: const PlatformDetails(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: const PlatformDetails(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: const PlatformDetails(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 deleted file mode 100644 index aa6d23fb56f5..000000000000 --- a/script/tool/test/xctest_command_test.dart +++ /dev/null @@ -1,606 +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'; - -// 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', - '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': '1E76A0FD-38AC-4537-A989-EA639D7D012A', - 'isAvailable': true, - 'deviceTypeIdentifier': - 'com.apple.CoreSimulator.SimDeviceType.iPhone-8-Plus', - 'state': 'Shutdown', - 'name': 'iPhone 8 Plus' - } - ] - } -}; - -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'), - ]), - ); - }); - - group('iOS', () { - test('skip if iOS is not supported', () async { - createFakePlugin('plugin', packagesDir, extraFiles: [ - 'example/test', - ], 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, extraFiles: [ - 'example/test', - ], 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, extraFiles: [ - 'example/test', - ], 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', - _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', - 'analyze', - '-workspace', - 'ios/Runner.xcworkspace', - '-configuration', - 'Debug', - '-scheme', - 'Runner', - '-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, extraFiles: [ - 'example/test', - ], 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)); - await runCapturingPrint(runner, ['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('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', () { - test('skip if macOS is not supported', () async { - createFakePlugin( - 'plugin', - packagesDir, - extraFiles: [ - 'example/test', - ], - ); - - final List output = await runCapturingPrint(runner, - ['xctest', '--macos', _kDestination, 'foo_destination']); - 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, extraFiles: [ - 'example/test', - ], platformSupport: { - kPlatformMacos: PlatformSupport.federated, - }); - - final List output = await runCapturingPrint(runner, - ['xctest', '--macos', _kDestination, 'foo_destination']); - 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, extraFiles: [ - 'example/test', - ], 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', - ]); - - expect( - output, - contains( - 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('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', () { - 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'); - - processRunner.processToReturn = MockProcess.succeeding(); - 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'); - - processRunner.processToReturn = MockProcess.succeeding(); - 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'); - - processRunner.processToReturn = MockProcess.succeeding(); - 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', - ]); - - processRunner.processToReturn = MockProcess.succeeding(); - 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([])); - }); - }); - }); -} diff --git a/script/tool_runner.sh b/script/tool_runner.sh index d16e940d5a4d..99bab387e6b6 100755 --- a/script/tool_runner.sh +++ b/script/tool_runner.sh @@ -5,74 +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" -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)"}" - -# 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[@]}" --exclude="$ALL_EXCLUDED" ${PLUGIN_SHARDING[@]}) -else - echo running "${ACTIONS[@]}" - (cd "$REPO_DIR" && plugin_tools "${ACTIONS[@]}" --run-on-changed-packages --exclude="$ALL_EXCLUDED" ${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