diff --git a/.ci/scripts/build_all_plugins.sh b/.ci/scripts/build_all_plugins.sh new file mode 100644 index 000000000000..008dea7c5e13 --- /dev/null +++ b/.ci/scripts/build_all_plugins.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +cd all_plugins +flutter build windows --debug +flutter build windows --release diff --git a/.ci/scripts/build_examples_uwp.sh b/.ci/scripts/build_examples_uwp.sh new file mode 100644 index 000000000000..639cb054e4b7 --- /dev/null +++ b/.ci/scripts/build_examples_uwp.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +dart ./script/tool/bin/flutter_plugin_tools.dart build-examples --winuwp \ + --packages-for-branch diff --git a/.ci/scripts/build_examples_win32.sh b/.ci/scripts/build_examples_win32.sh new file mode 100644 index 000000000000..8c090f4b78d2 --- /dev/null +++ b/.ci/scripts/build_examples_win32.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +dart ./script/tool/bin/flutter_plugin_tools.dart build-examples --windows \ + --packages-for-branch diff --git a/.ci/scripts/create_all_plugins_app.sh b/.ci/scripts/create_all_plugins_app.sh new file mode 100644 index 000000000000..196fef9b06c9 --- /dev/null +++ b/.ci/scripts/create_all_plugins_app.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +dart ./script/tool/bin/flutter_plugin_tools.dart all-plugins-app \ + --output-dir=. --exclude script/configs/exclude_all_plugins_app.yaml diff --git a/.ci/scripts/drive_examples_win32.sh b/.ci/scripts/drive_examples_win32.sh new file mode 100644 index 000000000000..63abc06bec5a --- /dev/null +++ b/.ci/scripts/drive_examples_win32.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +dart ./script/tool/bin/flutter_plugin_tools.dart drive-examples --windows \ + --packages-for-branch diff --git a/.ci/scripts/native_test_win32.sh b/.ci/scripts/native_test_win32.sh new file mode 100644 index 000000000000..938515784412 --- /dev/null +++ b/.ci/scripts/native_test_win32.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +dart ./script/tool/bin/flutter_plugin_tools.dart native-test --windows \ + --no-integration --packages-for-branch diff --git a/.ci/scripts/plugin_tools_tests.sh b/.ci/scripts/plugin_tools_tests.sh new file mode 100644 index 000000000000..96eec4349f08 --- /dev/null +++ b/.ci/scripts/plugin_tools_tests.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +cd script/tool +dart pub run test diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/regen_mocks.sh b/.ci/scripts/prepare_tool.sh old mode 100755 new mode 100644 similarity index 56% rename from packages/google_maps_flutter/google_maps_flutter_web/example/regen_mocks.sh rename to .ci/scripts/prepare_tool.sh index 78bcdc0f9e28..1095e2189a36 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/regen_mocks.sh +++ b/.ci/scripts/prepare_tool.sh @@ -1,10 +1,10 @@ -#!/usr/bin/bash +#!/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. -flutter pub get +# To set FETCH_HEAD for "git merge-base" to work +git fetch origin master -echo "(Re)generating mocks." - -flutter pub run build_runner build --delete-conflicting-outputs +cd script/tool +dart pub get diff --git a/.ci/targets/build_all_plugins.yaml b/.ci/targets/build_all_plugins.yaml new file mode 100644 index 000000000000..b51a5b18dfd9 --- /dev/null +++ b/.ci/targets/build_all_plugins.yaml @@ -0,0 +1,7 @@ +tasks: + - name: prepare tool + script: .ci/scripts/prepare_tool.sh + - name: create all_plugins app + script: .ci/scripts/create_all_plugins_app.sh + - name: build all_plugins + script: .ci/scripts/build_all_plugins.sh diff --git a/.ci/targets/plugin_tools_tests.yaml b/.ci/targets/plugin_tools_tests.yaml new file mode 100644 index 000000000000..265e74bdd06b --- /dev/null +++ b/.ci/targets/plugin_tools_tests.yaml @@ -0,0 +1,5 @@ +tasks: + - name: prepare tool + script: .ci/scripts/prepare_tool.sh + - name: tool unit tests + script: .ci/scripts/plugin_tools_tests.sh diff --git a/.ci/targets/uwp_build_and_platform_tests.yaml b/.ci/targets/uwp_build_and_platform_tests.yaml new file mode 100644 index 000000000000..a7f070776ff1 --- /dev/null +++ b/.ci/targets/uwp_build_and_platform_tests.yaml @@ -0,0 +1,5 @@ +tasks: + - name: prepare tool + script: .ci/scripts/prepare_tool.sh + - name: build examples (UWP) + script: .ci/scripts/build_examples_uwp.sh diff --git a/.ci/targets/windows_build_and_platform_tests.yaml b/.ci/targets/windows_build_and_platform_tests.yaml new file mode 100644 index 000000000000..cda3e57f75d2 --- /dev/null +++ b/.ci/targets/windows_build_and_platform_tests.yaml @@ -0,0 +1,9 @@ +tasks: + - name: prepare tool + script: .ci/scripts/prepare_tool.sh + - name: build examples (Win32) + script: .ci/scripts/build_examples_win32.sh + - name: native unit tests (Win32) + script: .ci/scripts/native_test_win32.sh + - name: drive examples (Win32) + script: .ci/scripts/drive_examples_win32.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000000..a64acf7692f9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: release +on: + push: + branches: + - master + +jobs: + release: + if: github.repository_owner == 'flutter' + name: release + permissions: + # Release needs to push a tag back to the repo. + contents: write + runs-on: ubuntu-latest + steps: + - name: "Install Flutter" + # Github Actions don't support templates so it is hard to share this snippet with another action + # If we eventually need to use this in more workflow, we could create a shell script that contains this + # snippet. + run: | + cd $HOME + git clone https://github.com/flutter/flutter.git --depth 1 -b stable _flutter + echo "$HOME/_flutter/bin" >> $GITHUB_PATH + cd $GITHUB_WORKSPACE + # Checks out a copy of the repo. + - name: Check out code + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + with: + fetch-depth: 0 # Fetch all history so the tool can get all the tags to determine version. + - name: Set up tools + run: dart pub get + working-directory: ${{ github.workspace }}/script/tool + + # This workflow should be the last to run. So wait for all the other tests to succeed. + - name: Wait on all tests + uses: lewagon/wait-on-check-action@5e937358caba2c7876a2ee06e4a48d0664fe4967 + with: + ref: ${{ github.sha }} + running-workflow-name: 'release' + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 180 # seconds + allowed-conclusions: success,neutral + # verbose:true will produce too many logs that hang github actions web UI. + verbose: false + + - name: run release + run: | + git config --global user.name ${{ secrets.USER_NAME }} + git config --global user.email ${{ secrets.USER_EMAIL }} + dart ./script/tool/lib/src/main.dart publish-plugin --all-changed --base-sha=HEAD~ --skip-confirmation --remote=origin + env: {PUB_CREDENTIALS: "${{ secrets.PUB_CREDENTIALS }}"} + + env: + DEFAULT_BRANCH: master diff --git a/packages/android_alarm_manager/android/lint-baseline.xml b/packages/android_alarm_manager/android/lint-baseline.xml new file mode 100644 index 000000000000..de588614fdb2 --- /dev/null +++ b/packages/android_alarm_manager/android/lint-baseline.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/android_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_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java b/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/android_intent/example/android/app/src/androidTestDebug/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/battery/battery/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/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/CameraCaptureCallback.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java new file mode 100644 index 000000000000..805f18298958 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraCaptureCallback.java @@ -0,0 +1,183 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera; + +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCaptureSession.CaptureCallback; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.CaptureResult; +import android.hardware.camera2.TotalCaptureResult; +import android.util.Log; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.types.CameraCaptureProperties; +import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; + +/** + * A callback object for tracking the progress of a {@link android.hardware.camera2.CaptureRequest} + * submitted to the camera device. + */ +class CameraCaptureCallback extends CaptureCallback { + private static final String TAG = "CameraCaptureCallback"; + private final CameraCaptureStateListener cameraStateListener; + private CameraState cameraState; + private final CaptureTimeoutsWrapper captureTimeouts; + private final CameraCaptureProperties captureProps; + + private CameraCaptureCallback( + @NonNull CameraCaptureStateListener cameraStateListener, + @NonNull CaptureTimeoutsWrapper captureTimeouts, + @NonNull CameraCaptureProperties captureProps) { + cameraState = CameraState.STATE_PREVIEW; + this.cameraStateListener = cameraStateListener; + this.captureTimeouts = captureTimeouts; + this.captureProps = captureProps; + } + + /** + * Creates a new instance of the {@link CameraCaptureCallback} class. + * + * @param cameraStateListener instance which will be called when the camera state changes. + * @param captureTimeouts specifying the different timeout counters that should be taken into + * account. + * @return a configured instance of the {@link CameraCaptureCallback} class. + */ + public static CameraCaptureCallback create( + @NonNull CameraCaptureStateListener cameraStateListener, + @NonNull CaptureTimeoutsWrapper captureTimeouts, + @NonNull CameraCaptureProperties captureProps) { + return new CameraCaptureCallback(cameraStateListener, captureTimeouts, captureProps); + } + + /** + * Gets the current {@link CameraState}. + * + * @return the current {@link CameraState}. + */ + public CameraState getCameraState() { + return cameraState; + } + + /** + * Sets the {@link CameraState}. + * + * @param state the camera is currently in. + */ + public void setCameraState(@NonNull CameraState state) { + cameraState = state; + } + + private void process(CaptureResult result) { + Integer aeState = result.get(CaptureResult.CONTROL_AE_STATE); + Integer afState = result.get(CaptureResult.CONTROL_AF_STATE); + + // 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, + "CameraCaptureCallback | state: " + + cameraState + + " | afState: " + + afState + + " | aeState: " + + aeState); + } + + switch (cameraState) { + case STATE_PREVIEW: + { + // We have nothing to do when the camera preview is working normally. + break; + } + case STATE_WAITING_FOCUS: + { + if (afState == null) { + return; + } else if (afState == CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED + || afState == CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED) { + handleWaitingFocusState(aeState); + } else if (captureTimeouts.getPreCaptureFocusing().getIsExpired()) { + Log.w(TAG, "Focus timeout, moving on with capture"); + handleWaitingFocusState(aeState); + } + + break; + } + case STATE_WAITING_PRECAPTURE_START: + { + // CONTROL_AE_STATE can be null on some devices + if (aeState == null + || aeState == CaptureResult.CONTROL_AE_STATE_CONVERGED + || aeState == CaptureResult.CONTROL_AE_STATE_PRECAPTURE + || aeState == CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED) { + setCameraState(CameraState.STATE_WAITING_PRECAPTURE_DONE); + } else if (captureTimeouts.getPreCaptureMetering().getIsExpired()) { + Log.w(TAG, "Metering timeout waiting for pre-capture to start, moving on with capture"); + + setCameraState(CameraState.STATE_WAITING_PRECAPTURE_DONE); + } + break; + } + case STATE_WAITING_PRECAPTURE_DONE: + { + // CONTROL_AE_STATE can be null on some devices + if (aeState == null || aeState != CaptureResult.CONTROL_AE_STATE_PRECAPTURE) { + cameraStateListener.onConverged(); + } else if (captureTimeouts.getPreCaptureMetering().getIsExpired()) { + Log.w( + TAG, "Metering timeout waiting for pre-capture to finish, moving on with capture"); + cameraStateListener.onConverged(); + } + + break; + } + } + } + + private void handleWaitingFocusState(Integer aeState) { + // CONTROL_AE_STATE can be null on some devices + if (aeState == null || aeState == CaptureRequest.CONTROL_AE_STATE_CONVERGED) { + cameraStateListener.onConverged(); + } else { + cameraStateListener.onPrecapture(); + } + } + + @Override + public void onCaptureProgressed( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull CaptureResult partialResult) { + process(partialResult); + } + + @Override + public void onCaptureCompleted( + @NonNull CameraCaptureSession session, + @NonNull CaptureRequest request, + @NonNull TotalCaptureResult result) { + process(result); + } + + /** An interface that describes the different state changes implementers can be informed about. */ + interface CameraCaptureStateListener { + + /** Called when the {@link android.hardware.camera2.CaptureRequest} has been converged. */ + void onConverged(); + + /** + * Called when the {@link android.hardware.camera2.CaptureRequest} enters the pre-capture state. + */ + void onPrecapture(); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java new file mode 100644 index 000000000000..951a2797d68f --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraRegionUtils.java @@ -0,0 +1,182 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera; + +import android.annotation.TargetApi; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.MeteringRectangle; +import android.os.Build; +import android.util.Size; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import java.util.Arrays; + +/** + * Utility class offering functions to calculate values regarding the camera boundaries. + * + *

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

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

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

This method is visible for testing purposes only and should never be used outside this * + * class. + * + * @param width width >= 0. + * @param height height >= 0. + * @return new instance of the {@link Size} class. + */ + @VisibleForTesting + public static Size create(int width, int height) { + return new Size(width, height); + } + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraState.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraState.java new file mode 100644 index 000000000000..ac48caf18ac6 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/CameraState.java @@ -0,0 +1,27 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera; + +/** + * These are the states that the camera can be in. The camera can only take one photo at a time so + * this state describes the state of the camera itself. The camera works like a pipeline where we + * feed it requests through. It can only process one tasks at a time. + */ +public enum CameraState { + /** Idle, showing preview and not capturing anything. */ + STATE_PREVIEW, + + /** Starting and waiting for autofocus to complete. */ + STATE_WAITING_FOCUS, + + /** Start performing autoexposure. */ + STATE_WAITING_PRECAPTURE_START, + + /** waiting for autoexposure to complete. */ + STATE_WAITING_PRECAPTURE_DONE, + + /** Capturing an image. */ + STATE_CAPTURING, +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/ImageSaver.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/ImageSaver.java new file mode 100644 index 000000000000..821c9a50c13f --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/ImageSaver.java @@ -0,0 +1,105 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera; + +import android.media.Image; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** Saves a JPEG {@link Image} into the specified {@link File}. */ +public class ImageSaver implements Runnable { + + /** The JPEG image */ + private final Image image; + + /** The file we save the image into. */ + private final File file; + + /** Used to report the status of the save action. */ + private final Callback callback; + + /** + * Creates an instance of the ImageSaver runnable + * + * @param image - The image to save + * @param file - The file to save the image to + * @param callback - The callback that is run on completion, or when an error is encountered. + */ + ImageSaver(@NonNull Image image, @NonNull File file, @NonNull Callback callback) { + this.image = image; + this.file = file; + this.callback = callback; + } + + @Override + public void run() { + ByteBuffer buffer = image.getPlanes()[0].getBuffer(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + FileOutputStream output = null; + try { + output = FileOutputStreamFactory.create(file); + output.write(bytes); + + callback.onComplete(file.getAbsolutePath()); + + } catch (IOException e) { + callback.onError("IOError", "Failed saving image"); + } finally { + image.close(); + if (null != output) { + try { + output.close(); + } catch (IOException e) { + callback.onError("cameraAccess", e.getMessage()); + } + } + } + } + + /** + * The interface for the callback that is passed to ImageSaver, for detecting completion or + * failure of the image saving task. + */ + public interface Callback { + /** + * Called when the image file has been saved successfully. + * + * @param absolutePath - The absolute path of the file that was saved. + */ + void onComplete(String absolutePath); + + /** + * Called when an error is encountered while saving the image file. + * + * @param errorCode - The error code. + * @param errorMessage - The human readable error message. + */ + void onError(String errorCode, String errorMessage); + } + + /** Factory class that assists in creating a {@link FileOutputStream} instance. */ + static class FileOutputStreamFactory { + /** + * Creates a new instance of the {@link FileOutputStream} class. + * + *

This method is visible for testing purposes only and should never be used outside this * + * class. + * + * @param file - The file to create the output stream for + * @return new instance of the {@link FileOutputStream} class. + * @throws FileNotFoundException when the supplied file could not be found. + */ + @VisibleForTesting + public static FileOutputStream create(File file) throws FileNotFoundException { + return new FileOutputStream(file); + } + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java new file mode 100644 index 000000000000..b91f9a1c03f7 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactory.java @@ -0,0 +1,149 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features; + +import android.app.Activity; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; + +/** + * Factory for creating the supported feature implementation controlling different aspects of the + * {@link android.hardware.camera2.CaptureRequest}. + */ +public interface CameraFeatureFactory { + + /** + * Creates a new instance of the auto focus feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @param recordingVideo indicates if the camera is currently recording. + * @return newly created instance of the AutoFocusFeature class. + */ + AutoFocusFeature createAutoFocusFeature( + @NonNull CameraProperties cameraProperties, boolean recordingVideo); + + /** + * Creates a new instance of the exposure lock feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the ExposureLockFeature class. + */ + ExposureLockFeature createExposureLockFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the exposure offset feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the ExposureOffsetFeature class. + */ + ExposureOffsetFeature createExposureOffsetFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the flash feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the FlashFeature class. + */ + FlashFeature createFlashFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the resolution feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @param initialSetting initial resolution preset. + * @param cameraName the name of the camera which can be used to identify the camera device. + * @return newly created instance of the ResolutionFeature class. + */ + ResolutionFeature createResolutionFeature( + @NonNull CameraProperties cameraProperties, + ResolutionPreset initialSetting, + String cameraName); + + /** + * Creates a new instance of the focus point feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @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, + @NonNull SensorOrientationFeature sensorOrientationFeature); + + /** + * Creates a new instance of the FPS range feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the FpsRangeFeature class. + */ + FpsRangeFeature createFpsRangeFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the sensor orientation feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @param activity current activity associated with the camera plugin. + * @param dartMessenger instance of the DartMessenger class, used to send state updates back to + * Dart. + * @return newly created instance of the SensorOrientationFeature class. + */ + SensorOrientationFeature createSensorOrientationFeature( + @NonNull CameraProperties cameraProperties, + @NonNull Activity activity, + @NonNull DartMessenger dartMessenger); + + /** + * Creates a new instance of the zoom level feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the ZoomLevelFeature class. + */ + ZoomLevelFeature createZoomLevelFeature(@NonNull CameraProperties cameraProperties); + + /** + * Creates a new instance of the exposure point feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @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, + @NonNull SensorOrientationFeature sensorOrientationFeature); + + /** + * Creates a new instance of the noise reduction feature. + * + * @param cameraProperties instance of the CameraProperties class containing information about the + * cameras features. + * @return newly created instance of the NoiseReductionFeature class. + */ + NoiseReductionFeature createNoiseReductionFeature(@NonNull CameraProperties cameraProperties); +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java new file mode 100644 index 000000000000..95a8c06caa0a --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatureFactoryImpl.java @@ -0,0 +1,98 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features; + +import android.app.Activity; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; + +/** + * Implementation of the {@link CameraFeatureFactory} interface creating the supported feature + * implementation controlling different aspects of the {@link + * android.hardware.camera2.CaptureRequest}. + */ +public class CameraFeatureFactoryImpl implements CameraFeatureFactory { + + @Override + public AutoFocusFeature createAutoFocusFeature( + @NonNull CameraProperties cameraProperties, boolean recordingVideo) { + return new AutoFocusFeature(cameraProperties, recordingVideo); + } + + @Override + public ExposureLockFeature createExposureLockFeature(@NonNull CameraProperties cameraProperties) { + return new ExposureLockFeature(cameraProperties); + } + + @Override + public ExposureOffsetFeature createExposureOffsetFeature( + @NonNull CameraProperties cameraProperties) { + return new ExposureOffsetFeature(cameraProperties); + } + + @Override + public FlashFeature createFlashFeature(@NonNull CameraProperties cameraProperties) { + return new FlashFeature(cameraProperties); + } + + @Override + public ResolutionFeature createResolutionFeature( + @NonNull CameraProperties cameraProperties, + ResolutionPreset initialSetting, + String cameraName) { + return new ResolutionFeature(cameraProperties, initialSetting, cameraName); + } + + @Override + public FocusPointFeature createFocusPointFeature( + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrientationFeature) { + return new FocusPointFeature(cameraProperties, sensorOrientationFeature); + } + + @Override + public FpsRangeFeature createFpsRangeFeature(@NonNull CameraProperties cameraProperties) { + return new FpsRangeFeature(cameraProperties); + } + + @Override + public SensorOrientationFeature createSensorOrientationFeature( + @NonNull CameraProperties cameraProperties, + @NonNull Activity activity, + @NonNull DartMessenger dartMessenger) { + return new SensorOrientationFeature(cameraProperties, activity, dartMessenger); + } + + @Override + public ZoomLevelFeature createZoomLevelFeature(@NonNull CameraProperties cameraProperties) { + return new ZoomLevelFeature(cameraProperties); + } + + @Override + public ExposurePointFeature createExposurePointFeature( + @NonNull CameraProperties cameraProperties, + @NonNull SensorOrientationFeature sensorOrientationFeature) { + return new ExposurePointFeature(cameraProperties, sensorOrientationFeature); + } + + @Override + public NoiseReductionFeature createNoiseReductionFeature( + @NonNull CameraProperties cameraProperties) { + return new NoiseReductionFeature(cameraProperties); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java new file mode 100644 index 000000000000..659fd15963e9 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/CameraFeatures.java @@ -0,0 +1,285 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features; + +import android.app.Activity; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * These are all of our available features in the camera. Used in the Camera to access all features + * in a simpler way. + */ +public class CameraFeatures { + private static final String AUTO_FOCUS = "AUTO_FOCUS"; + private static final String EXPOSURE_LOCK = "EXPOSURE_LOCK"; + private static final String EXPOSURE_OFFSET = "EXPOSURE_OFFSET"; + private static final String EXPOSURE_POINT = "EXPOSURE_POINT"; + private static final String FLASH = "FLASH"; + private static final String FOCUS_POINT = "FOCUS_POINT"; + private static final String FPS_RANGE = "FPS_RANGE"; + private static final String NOISE_REDUCTION = "NOISE_REDUCTION"; + private static final String REGION_BOUNDARIES = "REGION_BOUNDARIES"; + private static final String RESOLUTION = "RESOLUTION"; + private static final String SENSOR_ORIENTATION = "SENSOR_ORIENTATION"; + private static final String ZOOM_LEVEL = "ZOOM_LEVEL"; + + 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<>(); + + /** + * Gets a collection of all features that have been set. + * + * @return A collection of all features that have been set. + */ + public Collection getAllFeatures() { + return this.featureMap.values(); + } + + /** + * Gets the auto focus feature if it has been set. + * + * @return the auto focus feature. + */ + public AutoFocusFeature getAutoFocus() { + return (AutoFocusFeature) featureMap.get(AUTO_FOCUS); + } + + /** + * Sets the instance of the auto focus feature. + * + * @param autoFocus the {@link AutoFocusFeature} instance to set. + */ + public void setAutoFocus(AutoFocusFeature autoFocus) { + this.featureMap.put(AUTO_FOCUS, autoFocus); + } + + /** + * Gets the exposure lock feature if it has been set. + * + * @return the exposure lock feature. + */ + public ExposureLockFeature getExposureLock() { + return (ExposureLockFeature) featureMap.get(EXPOSURE_LOCK); + } + + /** + * Sets the instance of the exposure lock feature. + * + * @param exposureLock the {@link ExposureLockFeature} instance to set. + */ + public void setExposureLock(ExposureLockFeature exposureLock) { + this.featureMap.put(EXPOSURE_LOCK, exposureLock); + } + + /** + * Gets the exposure offset feature if it has been set. + * + * @return the exposure offset feature. + */ + public ExposureOffsetFeature getExposureOffset() { + return (ExposureOffsetFeature) featureMap.get(EXPOSURE_OFFSET); + } + + /** + * Sets the instance of the exposure offset feature. + * + * @param exposureOffset the {@link ExposureOffsetFeature} instance to set. + */ + public void setExposureOffset(ExposureOffsetFeature exposureOffset) { + this.featureMap.put(EXPOSURE_OFFSET, exposureOffset); + } + + /** + * Gets the exposure point feature if it has been set. + * + * @return the exposure point feature. + */ + public ExposurePointFeature getExposurePoint() { + return (ExposurePointFeature) featureMap.get(EXPOSURE_POINT); + } + + /** + * Sets the instance of the exposure point feature. + * + * @param exposurePoint the {@link ExposurePointFeature} instance to set. + */ + public void setExposurePoint(ExposurePointFeature exposurePoint) { + this.featureMap.put(EXPOSURE_POINT, exposurePoint); + } + + /** + * Gets the flash feature if it has been set. + * + * @return the flash feature. + */ + public FlashFeature getFlash() { + return (FlashFeature) featureMap.get(FLASH); + } + + /** + * Sets the instance of the flash feature. + * + * @param flash the {@link FlashFeature} instance to set. + */ + public void setFlash(FlashFeature flash) { + this.featureMap.put(FLASH, flash); + } + + /** + * Gets the focus point feature if it has been set. + * + * @return the focus point feature. + */ + public FocusPointFeature getFocusPoint() { + return (FocusPointFeature) featureMap.get(FOCUS_POINT); + } + + /** + * Sets the instance of the focus point feature. + * + * @param focusPoint the {@link FocusPointFeature} instance to set. + */ + public void setFocusPoint(FocusPointFeature focusPoint) { + this.featureMap.put(FOCUS_POINT, focusPoint); + } + + /** + * Gets the fps range feature if it has been set. + * + * @return the fps range feature. + */ + public FpsRangeFeature getFpsRange() { + return (FpsRangeFeature) featureMap.get(FPS_RANGE); + } + + /** + * Sets the instance of the fps range feature. + * + * @param fpsRange the {@link FpsRangeFeature} instance to set. + */ + public void setFpsRange(FpsRangeFeature fpsRange) { + this.featureMap.put(FPS_RANGE, fpsRange); + } + + /** + * Gets the noise reduction feature if it has been set. + * + * @return the noise reduction feature. + */ + public NoiseReductionFeature getNoiseReduction() { + return (NoiseReductionFeature) featureMap.get(NOISE_REDUCTION); + } + + /** + * Sets the instance of the noise reduction feature. + * + * @param noiseReduction the {@link NoiseReductionFeature} instance to set. + */ + public void setNoiseReduction(NoiseReductionFeature noiseReduction) { + this.featureMap.put(NOISE_REDUCTION, noiseReduction); + } + + /** + * Gets the resolution feature if it has been set. + * + * @return the resolution feature. + */ + public ResolutionFeature getResolution() { + return (ResolutionFeature) featureMap.get(RESOLUTION); + } + + /** + * Sets the instance of the resolution feature. + * + * @param resolution the {@link ResolutionFeature} instance to set. + */ + public void setResolution(ResolutionFeature resolution) { + this.featureMap.put(RESOLUTION, resolution); + } + + /** + * Gets the sensor orientation feature if it has been set. + * + * @return the sensor orientation feature. + */ + public SensorOrientationFeature getSensorOrientation() { + return (SensorOrientationFeature) featureMap.get(SENSOR_ORIENTATION); + } + + /** + * Sets the instance of the sensor orientation feature. + * + * @param sensorOrientation the {@link SensorOrientationFeature} instance to set. + */ + public void setSensorOrientation(SensorOrientationFeature sensorOrientation) { + this.featureMap.put(SENSOR_ORIENTATION, sensorOrientation); + } + + /** + * Gets the zoom level feature if it has been set. + * + * @return the zoom level feature. + */ + public ZoomLevelFeature getZoomLevel() { + return (ZoomLevelFeature) featureMap.get(ZOOM_LEVEL); + } + + /** + * Sets the instance of the zoom level feature. + * + * @param zoomLevel the {@link ZoomLevelFeature} instance to set. + */ + public void setZoomLevel(ZoomLevelFeature zoomLevel) { + this.featureMap.put(ZOOM_LEVEL, zoomLevel); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.java new file mode 100644 index 000000000000..a3a0172d3c37 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeature.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.camera.features.focuspoint; + +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; +import androidx.annotation.NonNull; +import io.flutter.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 { + + 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, SensorOrientationFeature sensorOrientationFeature) { + super(cameraProperties); + this.sensorOrientationFeature = sensorOrientationFeature; + } + + /** + * Sets the camera boundaries that are required for the focus point feature to function. + * + * @param cameraBoundaries - The camera boundaries to set. + */ + public void setCameraBoundaries(@NonNull Size cameraBoundaries) { + this.cameraBoundaries = cameraBoundaries; + this.buildFocusRectangle(); + } + + @Override + public String getDebugName() { + return "FocusPointFeature"; + } + + @Override + public Point getValue() { + return focusPoint; + } + + @Override + public void setValue(Point value) { + this.focusPoint = value == null || value.x == null || value.y == null ? null : value; + this.buildFocusRectangle(); + } + + // Whether or not this camera can set the focus point. + @Override + public boolean checkIsSupported() { + Integer supportedRegions = cameraProperties.getControlMaxRegionsAutoFocus(); + return supportedRegions != null && supportedRegions > 0; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + if (!checkIsSupported()) { + return; + } + requestBuilder.set( + CaptureRequest.CONTROL_AF_REGIONS, + focusRectangle == null ? null : new MeteringRectangle[] {focusRectangle}); + } + + private void buildFocusRectangle() { + if (this.cameraBoundaries == null) { + throw new AssertionError( + "The cameraBoundaries should be set (using `FocusPointFeature.setCameraBoundaries(Size)`) before updating the focus point."); + } + if (this.focusPoint == null) { + this.focusRectangle = null; + } else { + 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, orientation); + } + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeature.java new file mode 100644 index 000000000000..500f2aa28dc2 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeature.java @@ -0,0 +1,87 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.fpsrange; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build; +import android.util.Range; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.features.CameraFeature; + +/** + * Controls the frames per seconds (FPS) range configuration on the {@link android.hardware.camera2} + * API. + */ +public class FpsRangeFeature extends CameraFeature> { + private static final Range MAX_PIXEL4A_RANGE = new Range<>(30, 30); + private Range currentSetting; + + /** + * Creates a new instance of the {@link FpsRangeFeature}. + * + * @param cameraProperties Collection of characteristics for the current camera device. + */ + public FpsRangeFeature(CameraProperties cameraProperties) { + super(cameraProperties); + + if (isPixel4A()) { + // HACK: There is a bug in the Pixel 4A where it cannot support 60fps modes + // even though they are reported as supported by + // `getControlAutoExposureAvailableTargetFpsRanges`. + // For max device compatibility we will keep FPS under 60 even if they report they are + // capable of achieving 60 fps. Highest working FPS is 30. + // https://issuetracker.google.com/issues/189237151 + currentSetting = MAX_PIXEL4A_RANGE; + } else { + Range[] ranges = cameraProperties.getControlAutoExposureAvailableTargetFpsRanges(); + + if (ranges != null) { + for (Range range : ranges) { + int upper = range.getUpper(); + + if (upper >= 10) { + if (currentSetting == null || upper > currentSetting.getUpper()) { + currentSetting = range; + } + } + } + } + } + } + + private boolean isPixel4A() { + return Build.BRAND.equals("google") && Build.MODEL.equals("Pixel 4a"); + } + + @Override + public String getDebugName() { + return "FpsRangeFeature"; + } + + @Override + public Range getValue() { + return currentSetting; + } + + @Override + public void setValue(Range value) { + this.currentSetting = value; + } + + // Always supported + @Override + public boolean checkIsSupported() { + return true; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + if (!checkIsSupported()) { + return; + } + + requestBuilder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, currentSetting); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java new file mode 100644 index 000000000000..408575b375e6 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeature.java @@ -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. + +package io.flutter.plugins.camera.features.noisereduction; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.util.Log; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.features.CameraFeature; +import java.util.HashMap; + +/** + * This can either be enabled or disabled. Only full capability devices can set this to off. Legacy + * and full support the fast mode. + * https://developer.android.com/reference/android/hardware/camera2/CameraCharacteristics#NOISE_REDUCTION_AVAILABLE_NOISE_REDUCTION_MODES + */ +public class NoiseReductionFeature extends CameraFeature { + private NoiseReductionMode currentSetting = NoiseReductionMode.fast; + + private final HashMap NOISE_REDUCTION_MODES = new HashMap<>(); + + /** + * 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( + NoiseReductionMode.highQuality, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY); + if (VERSION.SDK_INT >= VERSION_CODES.M) { + NOISE_REDUCTION_MODES.put( + NoiseReductionMode.minimal, CaptureRequest.NOISE_REDUCTION_MODE_MINIMAL); + NOISE_REDUCTION_MODES.put( + NoiseReductionMode.zeroShutterLag, CaptureRequest.NOISE_REDUCTION_MODE_ZERO_SHUTTER_LAG); + } + } + + @Override + public String getDebugName() { + return "NoiseReductionFeature"; + } + + @Override + public NoiseReductionMode getValue() { + return currentSetting; + } + + @Override + public void setValue(NoiseReductionMode value) { + this.currentSetting = value; + } + + @Override + public boolean checkIsSupported() { + /* + * Available settings: public static final int NOISE_REDUCTION_MODE_FAST = 1; public static + * final int NOISE_REDUCTION_MODE_HIGH_QUALITY = 2; public static final int + * NOISE_REDUCTION_MODE_MINIMAL = 3; public static final int NOISE_REDUCTION_MODE_OFF = 0; + * public static final int NOISE_REDUCTION_MODE_ZERO_SHUTTER_LAG = 4; + * + *

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

When the supplied string doesn't match a valid {@see NoiseReductionMode} enum value, null is + * returned. + * + * @param modeStr String value to convert into an {@see NoiseReductionMode} enum value. + * @return Matching {@see NoiseReductionMode} enum value, or null if no match is found. + */ + public static NoiseReductionMode getValueForString(String modeStr) { + for (NoiseReductionMode value : values()) { + if (value.strValue.equals(modeStr)) return value; + } + return null; + } + + @Override + public String toString() { + return strValue; + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionFeature.java new file mode 100644 index 000000000000..afbd7c3758a6 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/resolution/ResolutionFeature.java @@ -0,0 +1,256 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.resolution; + +import android.annotation.TargetApi; +import android.hardware.camera2.CaptureRequest; +import android.media.CamcorderProfile; +import android.media.EncoderProfiles; +import android.os.Build; +import android.util.Size; +import androidx.annotation.VisibleForTesting; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.features.CameraFeature; +import java.util.List; + +/** + * Controls the resolutions configuration on the {@link android.hardware.camera2} API. + * + *

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

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

If the device's ACCELEROMETER_ROTATION setting is enabled the {@link + * DeviceOrientationManager} will report orientation updates based on the sensor information. If + * the ACCELEROMETER_ROTATION is disabled the {@link DeviceOrientationManager} will fallback to + * the deliver orientation updates based on the UI orientation. + */ + public void start() { + 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() { + if (broadcastReceiver == null) { + return; + } + activity.unregisterReceiver(broadcastReceiver); + broadcastReceiver = null; + } + + /** + * Returns the device's photo 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 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. + * + *

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 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 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. + * + *

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 video orientation in degrees. + */ + public int getVideoOrientation(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; + } + + /** @return the last received UI orientation. */ + public PlatformChannel.DeviceOrientation getLastUIOrientation() { + return this.lastOrientation; + } + + /** + * Handles orientation changes based on change events triggered by the OrientationIntentFilter. + * + *

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

This method is visible for testing purposes only and should never be used outside this + * class. + */ + @VisibleForTesting + static void handleOrientationChange( + DeviceOrientation newOrientation, + DeviceOrientation previousOrientation, + DartMessenger messenger) { + if (!newOrientation.equals(previousOrientation)) { + messenger.sendDeviceOrientationChangeEvent(newOrientation); + } + } + + /** + * Gets the current user interface orientation. + * + *

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

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

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

This method is visible for testing purposes only and should never be used outside this + * class. + * + * @return An instance of the Android {@link android.view.Display}. + */ + @SuppressWarnings("deprecation") + @VisibleForTesting + Display getDisplay() { + return ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeature.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeature.java new file mode 100644 index 000000000000..9e316f741805 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeature.java @@ -0,0 +1,105 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.sensororientation; + +import android.app.Activity; +import android.hardware.camera2.CameraMetadata; +import android.hardware.camera2.CaptureRequest; +import androidx.annotation.NonNull; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import io.flutter.plugins.camera.features.CameraFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; + +/** Provides access to the sensor orientation of the camera devices. */ +public class SensorOrientationFeature extends CameraFeature { + private Integer currentSetting = 0; + private final DeviceOrientationManager deviceOrientationListener; + private PlatformChannel.DeviceOrientation lockedCaptureOrientation; + + /** + * Creates a new instance of the {@link ResolutionFeature}. + * + * @param cameraProperties Collection of characteristics for the current camera device. + * @param activity Current Android {@link android.app.Activity}, used to detect UI orientation + * changes. + * @param dartMessenger Instance of a {@link DartMessenger} used to communicate orientation + * updates back to the client. + */ + public SensorOrientationFeature( + @NonNull CameraProperties cameraProperties, + @NonNull Activity activity, + @NonNull DartMessenger dartMessenger) { + super(cameraProperties); + setValue(cameraProperties.getSensorOrientation()); + + boolean isFrontFacing = cameraProperties.getLensFacing() == CameraMetadata.LENS_FACING_FRONT; + deviceOrientationListener = + DeviceOrientationManager.create(activity, dartMessenger, isFrontFacing, currentSetting); + deviceOrientationListener.start(); + } + + @Override + public String getDebugName() { + return "SensorOrientationFeature"; + } + + @Override + public Integer getValue() { + return currentSetting; + } + + @Override + public void setValue(Integer value) { + this.currentSetting = value; + } + + @Override + public boolean checkIsSupported() { + return true; + } + + @Override + public void updateBuilder(CaptureRequest.Builder requestBuilder) { + // Noop: when setting the sensor orientation there is no need to update the request builder. + } + + /** + * Gets the instance of the {@link DeviceOrientationManager} used to detect orientation changes. + * + * @return The instance of the {@link DeviceOrientationManager}. + */ + public DeviceOrientationManager getDeviceOrientationManager() { + return this.deviceOrientationListener; + } + + /** + * Lock the capture orientation, indicating that the device orientation should not influence the + * capture orientation. + * + * @param orientation The orientation in which to lock the capture orientation. + */ + public void lockCaptureOrientation(PlatformChannel.DeviceOrientation orientation) { + this.lockedCaptureOrientation = orientation; + } + + /** + * Unlock the capture orientation, indicating that the device orientation should be used to + * configure the capture orientation. + */ + public void unlockCaptureOrientation() { + this.lockedCaptureOrientation = null; + } + + /** + * Gets the configured locked capture orientation. + * + * @return The configured locked capture orientation. + */ + public PlatformChannel.DeviceOrientation getLockedCaptureOrientation() { + return this.lockedCaptureOrientation; + } +} diff --git a/packages/camera/camera/android/src/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/main/java/io/flutter/plugins/camera/types/CaptureTimeoutsWrapper.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CaptureTimeoutsWrapper.java new file mode 100644 index 000000000000..ad59bd09c754 --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/CaptureTimeoutsWrapper.java @@ -0,0 +1,52 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.types; + +/** + * Wrapper class that provides a container for all {@link Timeout} instances that are required for + * the capture flow. + */ +public class CaptureTimeoutsWrapper { + private Timeout preCaptureFocusing; + private Timeout preCaptureMetering; + private final long preCaptureFocusingTimeoutMs; + private final long preCaptureMeteringTimeoutMs; + + /** + * Create a new wrapper instance with the specified timeout values. + * + * @param preCaptureFocusingTimeoutMs focusing timeout milliseconds. + * @param preCaptureMeteringTimeoutMs metering timeout milliseconds. + */ + public CaptureTimeoutsWrapper( + long preCaptureFocusingTimeoutMs, long preCaptureMeteringTimeoutMs) { + this.preCaptureFocusingTimeoutMs = preCaptureFocusingTimeoutMs; + this.preCaptureMeteringTimeoutMs = preCaptureMeteringTimeoutMs; + } + + /** Reset all timeouts to the current timestamp. */ + public void reset() { + this.preCaptureFocusing = Timeout.create(preCaptureFocusingTimeoutMs); + this.preCaptureMetering = Timeout.create(preCaptureMeteringTimeoutMs); + } + + /** + * Returns the timeout instance related to precapture focusing. + * + * @return - The timeout object + */ + public Timeout getPreCaptureFocusing() { + return preCaptureFocusing; + } + + /** + * Returns the timeout instance related to precapture metering. + * + * @return - The timeout object + */ + public Timeout getPreCaptureMetering() { + return preCaptureMetering; + } +} diff --git a/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/Timeout.java b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/Timeout.java new file mode 100644 index 000000000000..67e05499d47a --- /dev/null +++ b/packages/camera/camera/android/src/main/java/io/flutter/plugins/camera/types/Timeout.java @@ -0,0 +1,51 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.types; + +import android.os.SystemClock; + +/** + * This is a simple class for managing a timeout. In the camera we generally keep two timeouts: one + * for focusing and one for pre-capture metering. + * + *

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

You generally check the status of the timeout in the CameraCaptureCallback during the capture + * sequence and use it to move to the next state if the timeout has passed. + */ +public class Timeout { + + /** The timeout time in milliseconds */ + private final long timeoutMs; + + /** When this timeout was started. Will be used later to check if the timeout has expired yet. */ + private final long timeStarted; + + /** + * Factory method to create a new Timeout. + * + * @param timeoutMs timeout to use. + * @return returns a new Timeout. + */ + public static Timeout create(long timeoutMs) { + return new Timeout(timeoutMs); + } + + /** + * Create a new timeout. + * + * @param timeoutMs the time in milliseconds for this timeout to lapse. + */ + private Timeout(long timeoutMs) { + this.timeoutMs = timeoutMs; + this.timeStarted = SystemClock.elapsedRealtime(); + } + + /** Will return true when the timeout period has lapsed. */ + public boolean getIsExpired() { + return (SystemClock.elapsedRealtime() - timeStarted) > timeoutMs; + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java new file mode 100644 index 000000000000..934aff857ec7 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraCaptureCallbackStatesTest.java @@ -0,0 +1,381 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.CaptureResult; +import android.hardware.camera2.CaptureResult.Key; +import android.hardware.camera2.TotalCaptureResult; +import io.flutter.plugins.camera.CameraCaptureCallback.CameraCaptureStateListener; +import io.flutter.plugins.camera.types.CameraCaptureProperties; +import io.flutter.plugins.camera.types.CaptureTimeoutsWrapper; +import io.flutter.plugins.camera.types.Timeout; +import io.flutter.plugins.camera.utils.TestUtils; +import java.util.HashMap; +import java.util.Map; +import junit.framework.TestCase; +import junit.framework.TestSuite; +import org.mockito.MockedStatic; + +public class CameraCaptureCallbackStatesTest extends TestCase { + private final Integer aeState; + private final Integer afState; + private final CameraState cameraState; + private final boolean isTimedOut; + + private Runnable validate; + + private CameraCaptureCallback cameraCaptureCallback; + private CameraCaptureStateListener mockCaptureStateListener; + private CameraCaptureSession mockCameraCaptureSession; + private CaptureRequest mockCaptureRequest; + private CaptureResult mockPartialCaptureResult; + private CaptureTimeoutsWrapper mockCaptureTimeouts; + private CameraCaptureProperties mockCaptureProps; + private TotalCaptureResult mockTotalCaptureResult; + private MockedStatic mockedStaticTimeout; + private Timeout mockTimeout; + + public static TestSuite suite() { + TestSuite suite = new TestSuite(); + + setUpPreviewStateTest(suite); + setUpWaitingFocusTests(suite); + setUpWaitingPreCaptureStartTests(suite); + setUpWaitingPreCaptureDoneTests(suite); + + return suite; + } + + protected CameraCaptureCallbackStatesTest( + String name, CameraState cameraState, Integer afState, Integer aeState) { + this(name, cameraState, afState, aeState, false); + } + + protected CameraCaptureCallbackStatesTest( + String name, CameraState cameraState, Integer afState, Integer aeState, boolean isTimedOut) { + super(name); + + this.aeState = aeState; + this.afState = afState; + this.cameraState = cameraState; + this.isTimedOut = isTimedOut; + } + + @Override + @SuppressWarnings("unchecked") + protected void setUp() throws Exception { + super.setUp(); + + mockedStaticTimeout = mockStatic(Timeout.class); + mockCaptureStateListener = mock(CameraCaptureStateListener.class); + mockCameraCaptureSession = mock(CameraCaptureSession.class); + mockCaptureRequest = mock(CaptureRequest.class); + mockPartialCaptureResult = mock(CaptureResult.class); + mockTotalCaptureResult = mock(TotalCaptureResult.class); + mockTimeout = mock(Timeout.class); + mockCaptureTimeouts = mock(CaptureTimeoutsWrapper.class); + mockCaptureProps = mock(CameraCaptureProperties.class); + when(mockCaptureTimeouts.getPreCaptureFocusing()).thenReturn(mockTimeout); + when(mockCaptureTimeouts.getPreCaptureMetering()).thenReturn(mockTimeout); + + Key mockAeStateKey = mock(Key.class); + Key mockAfStateKey = mock(Key.class); + + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AE_STATE", mockAeStateKey); + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AF_STATE", mockAfStateKey); + + mockedStaticTimeout.when(() -> Timeout.create(1000)).thenReturn(mockTimeout); + + cameraCaptureCallback = + CameraCaptureCallback.create( + mockCaptureStateListener, mockCaptureTimeouts, mockCaptureProps); + } + + @Override + protected void tearDown() throws Exception { + super.tearDown(); + + mockedStaticTimeout.close(); + + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AE_STATE", null); + TestUtils.setFinalStatic(CaptureResult.class, "CONTROL_AF_STATE", null); + } + + @Override + protected void runTest() throws Throwable { + when(mockPartialCaptureResult.get(CaptureResult.CONTROL_AF_STATE)).thenReturn(afState); + when(mockPartialCaptureResult.get(CaptureResult.CONTROL_AE_STATE)).thenReturn(aeState); + when(mockTotalCaptureResult.get(CaptureResult.CONTROL_AF_STATE)).thenReturn(afState); + when(mockTotalCaptureResult.get(CaptureResult.CONTROL_AE_STATE)).thenReturn(aeState); + + cameraCaptureCallback.setCameraState(cameraState); + if (isTimedOut) { + when(mockTimeout.getIsExpired()).thenReturn(true); + cameraCaptureCallback.onCaptureCompleted( + mockCameraCaptureSession, mockCaptureRequest, mockTotalCaptureResult); + } else { + cameraCaptureCallback.onCaptureProgressed( + mockCameraCaptureSession, mockCaptureRequest, mockPartialCaptureResult); + } + + validate.run(); + } + + private static void setUpPreviewStateTest(TestSuite suite) { + CameraCaptureCallbackStatesTest previewStateTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_or_pre_capture_when_state_is_preview", + CameraState.STATE_PREVIEW, + null, + null); + previewStateTest.validate = + () -> { + verify(previewStateTest.mockCaptureStateListener, never()).onConverged(); + verify(previewStateTest.mockCaptureStateListener, never()).onConverged(); + assertEquals( + CameraState.STATE_PREVIEW, previewStateTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(previewStateTest); + } + + private static void setUpWaitingFocusTests(TestSuite suite) { + Integer[] actionableAfStates = + new Integer[] { + CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED, + CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED + }; + + Integer[] nonActionableAfStates = + new Integer[] { + CaptureResult.CONTROL_AF_STATE_ACTIVE_SCAN, + CaptureResult.CONTROL_AF_STATE_INACTIVE, + CaptureResult.CONTROL_AF_STATE_PASSIVE_FOCUSED, + CaptureResult.CONTROL_AF_STATE_PASSIVE_SCAN, + CaptureResult.CONTROL_AF_STATE_PASSIVE_UNFOCUSED + }; + + Map aeStatesConvergeMap = + new HashMap() { + { + put(null, true); + put(CaptureResult.CONTROL_AE_STATE_CONVERGED, true); + put(CaptureResult.CONTROL_AE_STATE_PRECAPTURE, false); + put(CaptureResult.CONTROL_AE_STATE_LOCKED, false); + put(CaptureResult.CONTROL_AE_STATE_SEARCHING, false); + put(CaptureResult.CONTROL_AE_STATE_INACTIVE, false); + put(CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED, false); + } + }; + + CameraCaptureCallbackStatesTest nullStateTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_or_pre_capture_when_afstate_is_null", + CameraState.STATE_WAITING_FOCUS, + null, + null); + nullStateTest.validate = + () -> { + verify(nullStateTest.mockCaptureStateListener, never()).onConverged(); + verify(nullStateTest.mockCaptureStateListener, never()).onConverged(); + assertEquals( + CameraState.STATE_WAITING_FOCUS, + nullStateTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(nullStateTest); + + for (Integer afState : actionableAfStates) { + aeStatesConvergeMap.forEach( + (aeState, shouldConverge) -> { + CameraCaptureCallbackStatesTest focusLockedTest = + new CameraCaptureCallbackStatesTest( + "process_should_converge_when_af_state_is_" + + afState + + "_and_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_FOCUS, + afState, + aeState); + focusLockedTest.validate = + () -> { + if (shouldConverge) { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onConverged(); + verify(focusLockedTest.mockCaptureStateListener, never()).onPrecapture(); + } else { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onPrecapture(); + verify(focusLockedTest.mockCaptureStateListener, never()).onConverged(); + } + assertEquals( + CameraState.STATE_WAITING_FOCUS, + focusLockedTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(focusLockedTest); + }); + } + + for (Integer afState : nonActionableAfStates) { + CameraCaptureCallbackStatesTest focusLockedTest = + new CameraCaptureCallbackStatesTest( + "process_should_do_nothing_when_af_state_is_" + afState, + CameraState.STATE_WAITING_FOCUS, + afState, + null); + focusLockedTest.validate = + () -> { + verify(focusLockedTest.mockCaptureStateListener, never()).onConverged(); + verify(focusLockedTest.mockCaptureStateListener, never()).onPrecapture(); + assertEquals( + CameraState.STATE_WAITING_FOCUS, + focusLockedTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(focusLockedTest); + } + + for (Integer afState : nonActionableAfStates) { + aeStatesConvergeMap.forEach( + (aeState, shouldConverge) -> { + CameraCaptureCallbackStatesTest focusLockedTest = + new CameraCaptureCallbackStatesTest( + "process_should_converge_when_af_state_is_" + + afState + + "_and_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_FOCUS, + afState, + aeState, + true); + focusLockedTest.validate = + () -> { + if (shouldConverge) { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onConverged(); + verify(focusLockedTest.mockCaptureStateListener, never()).onPrecapture(); + } else { + verify(focusLockedTest.mockCaptureStateListener, times(1)).onPrecapture(); + verify(focusLockedTest.mockCaptureStateListener, never()).onConverged(); + } + assertEquals( + CameraState.STATE_WAITING_FOCUS, + focusLockedTest.cameraCaptureCallback.getCameraState()); + }; + suite.addTest(focusLockedTest); + }); + } + } + + private static void setUpWaitingPreCaptureStartTests(TestSuite suite) { + Map cameraStateMap = + new HashMap() { + { + put(null, CameraState.STATE_WAITING_PRECAPTURE_DONE); + put( + CaptureResult.CONTROL_AE_STATE_INACTIVE, + CameraState.STATE_WAITING_PRECAPTURE_START); + put( + CaptureResult.CONTROL_AE_STATE_SEARCHING, + CameraState.STATE_WAITING_PRECAPTURE_START); + put( + CaptureResult.CONTROL_AE_STATE_CONVERGED, + CameraState.STATE_WAITING_PRECAPTURE_DONE); + put(CaptureResult.CONTROL_AE_STATE_LOCKED, CameraState.STATE_WAITING_PRECAPTURE_START); + put( + CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED, + CameraState.STATE_WAITING_PRECAPTURE_DONE); + put( + CaptureResult.CONTROL_AE_STATE_PRECAPTURE, + CameraState.STATE_WAITING_PRECAPTURE_DONE); + } + }; + + cameraStateMap.forEach( + (aeState, cameraState) -> { + CameraCaptureCallbackStatesTest testCase = + new CameraCaptureCallbackStatesTest( + "process_should_update_camera_state_to_waiting_pre_capture_done_when_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_PRECAPTURE_START, + null, + aeState); + testCase.validate = + () -> assertEquals(cameraState, testCase.cameraCaptureCallback.getCameraState()); + suite.addTest(testCase); + }); + + cameraStateMap.forEach( + (aeState, cameraState) -> { + if (cameraState == CameraState.STATE_WAITING_PRECAPTURE_DONE) { + return; + } + + CameraCaptureCallbackStatesTest testCase = + new CameraCaptureCallbackStatesTest( + "process_should_update_camera_state_to_waiting_pre_capture_done_when_ae_state_is_" + + aeState, + CameraState.STATE_WAITING_PRECAPTURE_START, + null, + aeState, + true); + testCase.validate = + () -> + assertEquals( + CameraState.STATE_WAITING_PRECAPTURE_DONE, + testCase.cameraCaptureCallback.getCameraState()); + suite.addTest(testCase); + }); + } + + private static void setUpWaitingPreCaptureDoneTests(TestSuite suite) { + Integer[] onConvergeStates = + new Integer[] { + null, + CaptureResult.CONTROL_AE_STATE_CONVERGED, + CaptureResult.CONTROL_AE_STATE_LOCKED, + CaptureResult.CONTROL_AE_STATE_SEARCHING, + CaptureResult.CONTROL_AE_STATE_INACTIVE, + CaptureResult.CONTROL_AE_STATE_FLASH_REQUIRED, + }; + + for (Integer aeState : onConvergeStates) { + CameraCaptureCallbackStatesTest shouldConvergeTest = + new CameraCaptureCallbackStatesTest( + "process_should_converge_when_ae_state_is_" + aeState, + CameraState.STATE_WAITING_PRECAPTURE_DONE, + null, + null); + shouldConvergeTest.validate = + () -> verify(shouldConvergeTest.mockCaptureStateListener, times(1)).onConverged(); + suite.addTest(shouldConvergeTest); + } + + CameraCaptureCallbackStatesTest shouldNotConvergeTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_when_ae_state_is_pre_capture", + CameraState.STATE_WAITING_PRECAPTURE_DONE, + null, + CaptureResult.CONTROL_AE_STATE_PRECAPTURE); + shouldNotConvergeTest.validate = + () -> verify(shouldNotConvergeTest.mockCaptureStateListener, never()).onConverged(); + suite.addTest(shouldNotConvergeTest); + + CameraCaptureCallbackStatesTest shouldConvergeWhenTimedOutTest = + new CameraCaptureCallbackStatesTest( + "process_should_not_converge_when_ae_state_is_pre_capture", + CameraState.STATE_WAITING_PRECAPTURE_DONE, + null, + CaptureResult.CONTROL_AE_STATE_PRECAPTURE, + true); + shouldConvergeWhenTimedOutTest.validate = + () -> + verify(shouldConvergeWhenTimedOutTest.mockCaptureStateListener, times(1)).onConverged(); + suite.addTest(shouldConvergeWhenTimedOutTest); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/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/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/CameraRegionUtils_getCameraBoundariesTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_getCameraBoundariesTest.java new file mode 100644 index 000000000000..4c0164981b74 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraRegionUtils_getCameraBoundariesTest.java @@ -0,0 +1,247 @@ +// Copyright 2013 The Flutter 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.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.graphics.Rect; +import android.hardware.camera2.CaptureRequest; +import android.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.stubbing.Answer; + +public class CameraRegionUtils_getCameraBoundariesTest { + + Size mockCameraBoundaries; + + @Before + public void setUp() { + this.mockCameraBoundaries = mock(Size.class); + when(this.mockCameraBoundaries.getWidth()).thenReturn(100); + when(this.mockCameraBoundaries.getHeight()).thenReturn(100); + } + + @Test + public void getCameraBoundaries_shouldReturnSensorInfoPixelArraySizeWhenRunningPreAndroidP() { + updateSdkVersion(Build.VERSION_CODES.O_MR1); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockCameraBoundaries); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(mockCameraBoundaries, result); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_shouldReturnSensorInfoPixelArraySizeWhenDistortionCorrectionIsNull() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()).thenReturn(null); + when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockCameraBoundaries); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(mockCameraBoundaries, result); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_shouldReturnSensorInfoPixelArraySizeWhenDistortionCorrectionIsOff() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn(new int[] {CaptureRequest.DISTORTION_CORRECTION_MODE_OFF}); + when(mockCameraProperties.getSensorInfoPixelArraySize()).thenReturn(mockCameraBoundaries); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(mockCameraBoundaries, result); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_shouldReturnInfoPreCorrectionActiveArraySizeWhenDistortionCorrectionModeIsSetToNull() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + Rect mockSensorInfoPreCorrectionActiveArraySize = mock(Rect.class); + when(mockSensorInfoPreCorrectionActiveArraySize.width()).thenReturn(100); + when(mockSensorInfoPreCorrectionActiveArraySize.height()).thenReturn(100); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn( + new int[] { + CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, + CaptureRequest.DISTORTION_CORRECTION_MODE_FAST + }); + when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)).thenReturn(null); + when(mockCameraProperties.getSensorInfoPreCorrectionActiveArraySize()) + .thenReturn(mockSensorInfoPreCorrectionActiveArraySize); + + try (MockedStatic mockedSizeFactory = + mockStatic(CameraRegionUtils.SizeFactory.class)) { + mockedSizeFactory + .when(() -> CameraRegionUtils.SizeFactory.create(anyInt(), anyInt())) + .thenAnswer( + (Answer) + invocation -> { + Size mockSize = mock(Size.class); + when(mockSize.getWidth()).thenReturn(invocation.getArgument(0)); + when(mockSize.getHeight()).thenReturn(invocation.getArgument(1)); + return mockSize; + }); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(100, result.getWidth()); + assertEquals(100, result.getHeight()); + verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_shouldReturnInfoPreCorrectionActiveArraySizeWhenDistortionCorrectionModeIsSetToOff() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + Rect mockSensorInfoPreCorrectionActiveArraySize = mock(Rect.class); + when(mockSensorInfoPreCorrectionActiveArraySize.width()).thenReturn(100); + when(mockSensorInfoPreCorrectionActiveArraySize.height()).thenReturn(100); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn( + new int[] { + CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, + CaptureRequest.DISTORTION_CORRECTION_MODE_FAST + }); + + when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)) + .thenReturn(CaptureRequest.DISTORTION_CORRECTION_MODE_OFF); + when(mockCameraProperties.getSensorInfoPreCorrectionActiveArraySize()) + .thenReturn(mockSensorInfoPreCorrectionActiveArraySize); + + try (MockedStatic mockedSizeFactory = + mockStatic(CameraRegionUtils.SizeFactory.class)) { + mockedSizeFactory + .when(() -> CameraRegionUtils.SizeFactory.create(anyInt(), anyInt())) + .thenAnswer( + (Answer) + invocation -> { + Size mockSize = mock(Size.class); + when(mockSize.getWidth()).thenReturn(invocation.getArgument(0)); + when(mockSize.getHeight()).thenReturn(invocation.getArgument(1)); + return mockSize; + }); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(100, result.getWidth()); + assertEquals(100, result.getHeight()); + verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); + verify(mockCameraProperties, never()).getSensorInfoActiveArraySize(); + } + } finally { + updateSdkVersion(0); + } + } + + @Test + public void + getCameraBoundaries_shouldReturnSensorInfoActiveArraySizeWhenDistortionCorrectionModeIsSet() { + updateSdkVersion(Build.VERSION_CODES.P); + + try { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + Rect mockSensorInfoActiveArraySize = mock(Rect.class); + when(mockSensorInfoActiveArraySize.width()).thenReturn(100); + when(mockSensorInfoActiveArraySize.height()).thenReturn(100); + + when(mockCameraProperties.getDistortionCorrectionAvailableModes()) + .thenReturn( + new int[] { + CaptureRequest.DISTORTION_CORRECTION_MODE_OFF, + CaptureRequest.DISTORTION_CORRECTION_MODE_FAST + }); + + when(mockBuilder.get(CaptureRequest.DISTORTION_CORRECTION_MODE)) + .thenReturn(CaptureRequest.DISTORTION_CORRECTION_MODE_FAST); + when(mockCameraProperties.getSensorInfoActiveArraySize()) + .thenReturn(mockSensorInfoActiveArraySize); + + try (MockedStatic mockedSizeFactory = + mockStatic(CameraRegionUtils.SizeFactory.class)) { + mockedSizeFactory + .when(() -> CameraRegionUtils.SizeFactory.create(anyInt(), anyInt())) + .thenAnswer( + (Answer) + invocation -> { + Size mockSize = mock(Size.class); + when(mockSize.getWidth()).thenReturn(invocation.getArgument(0)); + when(mockSize.getHeight()).thenReturn(invocation.getArgument(1)); + return mockSize; + }); + + Size result = CameraRegionUtils.getCameraBoundaries(mockCameraProperties, mockBuilder); + + assertEquals(100, result.getWidth()); + assertEquals(100, result.getHeight()); + verify(mockCameraProperties, never()).getSensorInfoPixelArraySize(); + verify(mockCameraProperties, never()).getSensorInfoPreCorrectionActiveArraySize(); + } + } finally { + updateSdkVersion(0); + } + } + + 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..1ed2e4c11d7b --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -0,0 +1,900 @@ +// Copyright 2013 The Flutter 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.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +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.MediaRecorder; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.LifecycleObserver; +import io.flutter.embedding.engine.systemchannels.PlatformChannel; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.camera.features.CameraFeatureFactory; +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; +import org.mockito.MockedStatic; + +public class CameraTest { + private CameraProperties mockCameraProperties; + private CameraFeatureFactory mockCameraFeatureFactory; + private DartMessenger mockDartMessenger; + private Camera camera; + private CameraCaptureSession mockCaptureSession; + private CaptureRequest.Builder mockPreviewRequestBuilder; + private MockedStatic mockHandlerThreadFactory; + private HandlerThread mockHandlerThread; + private MockedStatic mockHandlerFactory; + private Handler mockHandler; + + @Before + public void before() { + mockCameraProperties = mock(CameraProperties.class); + mockCameraFeatureFactory = new TestCameraFeatureFactory(); + mockDartMessenger = mock(DartMessenger.class); + mockCaptureSession = mock(CameraCaptureSession.class); + mockPreviewRequestBuilder = mock(CaptureRequest.Builder.class); + mockHandlerThreadFactory = mockStatic(Camera.HandlerThreadFactory.class); + mockHandlerThread = mock(HandlerThread.class); + mockHandlerFactory = mockStatic(Camera.HandlerFactory.class); + mockHandler = mock(Handler.class); + + final Activity mockActivity = mock(Activity.class); + final TextureRegistry.SurfaceTextureEntry mockFlutterTexture = + mock(TextureRegistry.SurfaceTextureEntry.class); + final String cameraName = "1"; + final ResolutionPreset resolutionPreset = ResolutionPreset.high; + final boolean enableAudio = false; + + when(mockCameraProperties.getCameraName()).thenReturn(cameraName); + mockHandlerFactory.when(() -> Camera.HandlerFactory.create(any())).thenReturn(mockHandler); + mockHandlerThreadFactory + .when(() -> Camera.HandlerThreadFactory.create(any())) + .thenReturn(mockHandlerThread); + + camera = + new Camera( + 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); + mockHandlerThreadFactory.close(); + mockHandlerFactory.close(); + } + + @Test + public void shouldNotImplementLifecycleObserverInterface() { + Class cameraClass = Camera.class; + + assertFalse(LifecycleObserver.class.isAssignableFrom(cameraClass)); + } + + @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 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()); + } + + @Test + public void startBackgroundThread_shouldStartNewThread() { + camera.startBackgroundThread(); + + verify(mockHandlerThread, times(1)).start(); + assertEquals(mockHandler, TestUtils.getPrivateField(camera, "backgroundHandler")); + } + + @Test + public void startBackgroundThread_shouldNotStartNewThreadWhenAlreadyCreated() { + camera.startBackgroundThread(); + camera.startBackgroundThread(); + + verify(mockHandlerThread, times(1)).start(); + } + + private static class TestCameraFeatureFactory implements CameraFeatureFactory { + private final AutoFocusFeature mockAutoFocusFeature; + private final ExposureLockFeature mockExposureLockFeature; + 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/CameraTest_getRecordingProfileTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest_getRecordingProfileTest.java new file mode 100644 index 000000000000..04bab14f26ac --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/CameraTest_getRecordingProfileTest.java @@ -0,0 +1,205 @@ +// Copyright 2013 The Flutter 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.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.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CaptureRequest; +import android.media.CamcorderProfile; +import android.media.EncoderProfiles; +import android.os.Handler; +import android.os.HandlerThread; +import androidx.annotation.NonNull; +import io.flutter.plugins.camera.features.CameraFeatureFactory; +import io.flutter.plugins.camera.features.autofocus.AutoFocusFeature; +import io.flutter.plugins.camera.features.exposurelock.ExposureLockFeature; +import io.flutter.plugins.camera.features.exposureoffset.ExposureOffsetFeature; +import io.flutter.plugins.camera.features.exposurepoint.ExposurePointFeature; +import io.flutter.plugins.camera.features.flash.FlashFeature; +import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; +import io.flutter.plugins.camera.features.noisereduction.NoiseReductionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionFeature; +import io.flutter.plugins.camera.features.resolution.ResolutionPreset; +import io.flutter.plugins.camera.features.sensororientation.SensorOrientationFeature; +import io.flutter.plugins.camera.features.zoomlevel.ZoomLevelFeature; +import io.flutter.view.TextureRegistry; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +public class CameraTest_getRecordingProfileTest { + + private CameraProperties mockCameraProperties; + private CameraFeatureFactory mockCameraFeatureFactory; + private DartMessenger mockDartMessenger; + private Camera camera; + private CameraCaptureSession mockCaptureSession; + private CaptureRequest.Builder mockPreviewRequestBuilder; + private MockedStatic mockHandlerThreadFactory; + private HandlerThread mockHandlerThread; + private MockedStatic mockHandlerFactory; + private Handler mockHandler; + + @Before + public void before() { + mockCameraProperties = mock(CameraProperties.class); + mockCameraFeatureFactory = new TestCameraFeatureFactory(); + mockDartMessenger = mock(DartMessenger.class); + + final Activity mockActivity = mock(Activity.class); + final TextureRegistry.SurfaceTextureEntry mockFlutterTexture = + mock(TextureRegistry.SurfaceTextureEntry.class); + final ResolutionPreset resolutionPreset = ResolutionPreset.high; + final boolean enableAudio = false; + + camera = + new Camera( + mockActivity, + mockFlutterTexture, + mockCameraFeatureFactory, + mockDartMessenger, + mockCameraProperties, + resolutionPreset, + enableAudio); + } + + @Config(maxSdk = 30) + @Test + public void getRecordingProfileLegacy() { + ResolutionFeature mockResolutionFeature = + mockCameraFeatureFactory.createResolutionFeature(mockCameraProperties, null, null); + CamcorderProfile mockCamcorderProfile = mock(CamcorderProfile.class); + + when(mockResolutionFeature.getRecordingProfileLegacy()).thenReturn(mockCamcorderProfile); + + CamcorderProfile actualRecordingProfile = camera.getRecordingProfileLegacy(); + + verify(mockResolutionFeature, times(1)).getRecordingProfileLegacy(); + assertEquals(mockCamcorderProfile, actualRecordingProfile); + } + + @Config(minSdk = 31) + @Test + public void getRecordingProfile() { + ResolutionFeature mockResolutionFeature = + mockCameraFeatureFactory.createResolutionFeature(mockCameraProperties, null, null); + EncoderProfiles mockRecordingProfile = mock(EncoderProfiles.class); + + when(mockResolutionFeature.getRecordingProfile()).thenReturn(mockRecordingProfile); + + EncoderProfiles actualRecordingProfile = camera.getRecordingProfile(); + + verify(mockResolutionFeature, times(1)).getRecordingProfile(); + assertEquals(mockRecordingProfile, actualRecordingProfile); + } + + 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/ImageSaverTests.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java new file mode 100644 index 000000000000..0358ce6cb785 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/ImageSaverTests.java @@ -0,0 +1,105 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.media.Image; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +public class ImageSaverTests { + + Image mockImage; + File mockFile; + ImageSaver.Callback mockCallback; + ImageSaver imageSaver; + Image.Plane mockPlane; + ByteBuffer mockBuffer; + MockedStatic mockFileOutputStreamFactory; + FileOutputStream mockFileOutputStream; + + @Before + public void setup() { + // Set up mocked file dependency + mockFile = mock(File.class); + when(mockFile.getAbsolutePath()).thenReturn("absolute/path"); + mockPlane = mock(Image.Plane.class); + mockBuffer = mock(ByteBuffer.class); + when(mockBuffer.remaining()).thenReturn(3); + when(mockBuffer.get(any())) + .thenAnswer( + new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + byte[] bytes = invocation.getArgument(0); + bytes[0] = 0x42; + bytes[1] = 0x00; + bytes[2] = 0x13; + return mockBuffer; + } + }); + + // Set up mocked image dependency + mockImage = mock(Image.class); + when(mockPlane.getBuffer()).thenReturn(mockBuffer); + when(mockImage.getPlanes()).thenReturn(new Image.Plane[] {mockPlane}); + + // Set up mocked FileOutputStream + mockFileOutputStreamFactory = mockStatic(ImageSaver.FileOutputStreamFactory.class); + mockFileOutputStream = mock(FileOutputStream.class); + mockFileOutputStreamFactory + .when(() -> ImageSaver.FileOutputStreamFactory.create(any())) + .thenReturn(mockFileOutputStream); + + // Set up testable ImageSaver instance + mockCallback = mock(ImageSaver.Callback.class); + imageSaver = new ImageSaver(mockImage, mockFile, mockCallback); + } + + @After + public void teardown() { + mockFileOutputStreamFactory.close(); + } + + @Test + public void runWritesBytesToFileAndFinishesWithPath() throws IOException { + imageSaver.run(); + + verify(mockFileOutputStream, times(1)).write(new byte[] {0x42, 0x00, 0x13}); + verify(mockCallback, times(1)).onComplete("absolute/path"); + verify(mockCallback, never()).onError(any(), any()); + } + + @Test + public void runCallsErrorOnWriteIoexception() throws IOException { + doThrow(new IOException()).when(mockFileOutputStream).write(any()); + imageSaver.run(); + verify(mockCallback, times(1)).onError("IOError", "Failed saving image"); + verify(mockCallback, never()).onComplete(any()); + } + + @Test + public void 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..868e2e9e6d57 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/MethodCallHandlerImplTest.java @@ -0,0 +1,77 @@ +// Copyright 2013 The Flutter 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.assertFalse; +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 androidx.lifecycle.LifecycleObserver; +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)); + mockResult = mock(MethodChannel.Result.class); + mockCamera = mock(Camera.class); + TestUtils.setPrivateField(handler, "camera", mockCamera); + } + + @Test + public void shouldNotImplementLifecycleObserverInterface() { + Class methodCallHandlerClass = MethodCallHandlerImpl.class; + + assertFalse(LifecycleObserver.class.isAssignableFrom(methodCallHandlerClass)); + } + + @Test + public void onMethodCall_pausePreview_shouldPausePreviewAndSendSuccessResult() + throws CameraAccessException { + 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/features/focuspoint/FocusPointFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java new file mode 100644 index 000000000000..f03dc9f62e87 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/focuspoint/FocusPointFeatureTest.java @@ -0,0 +1,318 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.focuspoint; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.MeteringRectangle; +import android.util.Size; +import io.flutter.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; +import org.mockito.Mockito; + +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_shouldReturnTheNameOfTheFeature() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + + assertEquals("FocusPointFeature", focusPointFeature.getDebugName()); + } + + @Test + public void getValue_shouldReturnNullIfNotSet() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + Point actualPoint = focusPointFeature.getValue(); + assertNull(focusPointFeature.getValue()); + } + + @Test + public void getValue_shouldEchoTheSetValue() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + Point expectedPoint = new Point(0.0, 0.0); + + focusPointFeature.setValue(expectedPoint); + Point actualPoint = focusPointFeature.getValue(); + + assertEquals(expectedPoint, actualPoint); + } + + @Test + public void setValue_shouldResetPointWhenXCoordIsNull() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + + focusPointFeature.setValue(new Point(null, 0.0)); + + assertNull(focusPointFeature.getValue()); + } + + @Test + public void setValue_shouldResetPointWhenYCoordIsNull() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + + focusPointFeature.setValue(new Point(0.0, null)); + + assertNull(focusPointFeature.getValue()); + } + + @Test + public void setValue_shouldSetPointWhenValidCoordsAreSupplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + Point point = new Point(0.0, 0.0); + + focusPointFeature.setValue(point); + + assertEquals(point, focusPointFeature.getValue()); + } + + @Test + public void setValue_shouldDetermineMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + Size mockedCameraBoundaries = mock(Size.class); + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + focusPointFeature.setValue(new Point(0.5, 0.5)); + + mockedCameraRegionUtils.verify( + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT), + times(1)); + } + } + + @Test(expected = AssertionError.class) + public void setValue_shouldThrowAssertionErrorWhenNoValidBoundariesAreSet() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + focusPointFeature.setValue(new Point(0.5, 0.5)); + } + } + + @Test + public void setValue_shouldNotDetermineMeteringRectangleWhenNullCoordsAreSet() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + Size mockedCameraBoundaries = mock(Size.class); + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + focusPointFeature.setValue(null); + focusPointFeature.setValue(new Point(null, 0.5)); + focusPointFeature.setValue(new Point(0.5, null)); + + mockedCameraRegionUtils.verifyNoInteractions(); + } + } + + @Test + public void + setCameraBoundaries_shouldDetermineMeteringRectangleWhenValidBoundariesAndCoordsAreSupplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + focusPointFeature.setValue(new Point(0.5, 0.5)); + Size mockedCameraBoundaries = mock(Size.class); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + + mockedCameraRegionUtils.verify( + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT), + times(1)); + } + } + + @Test + public void checkIsSupported_shouldReturnFalseWhenMaxRegionsIsNull() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(new Size(100, 100)); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(null); + + assertFalse(focusPointFeature.checkIsSupported()); + } + + @Test + public void checkIsSupported_shouldReturnFalseWhenMaxRegionsIsZero() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(new Size(100, 100)); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(0); + + assertFalse(focusPointFeature.checkIsSupported()); + } + + @Test + public void checkIsSupported_shouldReturnTrueWhenMaxRegionsIsBiggerThenZero() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(new Size(100, 100)); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + + assertTrue(focusPointFeature.checkIsSupported()); + } + + @Test + public void updateBuilder_shouldReturnWhenCheckIsSupportedIsFalse() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(0); + + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + + verify(mockCaptureRequestBuilder, never()).set(any(), any()); + } + + @Test + 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, mockSensorOrientationFeature); + Size mockedCameraBoundaries = mock(Size.class); + MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); + + try (MockedStatic mockedCameraRegionUtils = + Mockito.mockStatic(CameraRegionUtils.class)) { + mockedCameraRegionUtils + .when( + () -> + CameraRegionUtils.convertPointToMeteringRectangle( + mockedCameraBoundaries, + 0.5, + 0.5, + PlatformChannel.DeviceOrientation.LANDSCAPE_LEFT)) + .thenReturn(mockedMeteringRectangle); + focusPointFeature.setCameraBoundaries(mockedCameraBoundaries); + focusPointFeature.setValue(new Point(0.5, 0.5)); + + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + } + + verify(mockCaptureRequestBuilder, times(1)) + .set(CaptureRequest.CONTROL_AE_REGIONS, new MeteringRectangle[] {mockedMeteringRectangle}); + } + + @Test + public void updateBuilder_shouldNotSetMeteringRectangleWhenNoValidBoundariesAreSupplied() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + when(mockCameraProperties.getControlMaxRegionsAutoFocus()).thenReturn(1); + CaptureRequest.Builder mockCaptureRequestBuilder = mock(CaptureRequest.Builder.class); + FocusPointFeature focusPointFeature = + new FocusPointFeature(mockCameraProperties, mockSensorOrientationFeature); + MeteringRectangle mockedMeteringRectangle = mock(MeteringRectangle.class); + + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + + verify(mockCaptureRequestBuilder, times(1)).set(any(), isNull()); + } + + @Test + 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, mockSensorOrientationFeature); + focusPointFeature.setCameraBoundaries(this.mockCameraBoundaries); + + focusPointFeature.setValue(null); + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + focusPointFeature.setValue(new Point(0d, null)); + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + focusPointFeature.setValue(new Point(null, 0d)); + focusPointFeature.updateBuilder(mockCaptureRequestBuilder); + verify(mockCaptureRequestBuilder, times(3)).set(any(), isNull()); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java new file mode 100644 index 000000000000..93cfe5523df3 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeaturePixel4aTest.java @@ -0,0 +1,30 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.fpsrange; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +import android.os.Build; +import android.util.Range; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.utils.TestUtils; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class FpsRangeFeaturePixel4aTest { + @Test + public void ctor_shouldInitializeFpsRangeWith30WhenDeviceIsPixel4a() { + TestUtils.setFinalStatic(Build.class, "BRAND", "google"); + TestUtils.setFinalStatic(Build.class, "MODEL", "Pixel 4a"); + + FpsRangeFeature fpsRangeFeature = new FpsRangeFeature(mock(CameraProperties.class)); + Range range = fpsRangeFeature.getValue(); + assertEquals(30, (int) range.getLower()); + assertEquals(30, (int) range.getUpper()); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java new file mode 100644 index 000000000000..2bb4d849a277 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/fpsrange/FpsRangeFeatureTest.java @@ -0,0 +1,108 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.fpsrange; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build; +import android.util.Range; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.utils.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class FpsRangeFeatureTest { + @Before + public void before() { + TestUtils.setFinalStatic(Build.class, "BRAND", "Test Brand"); + TestUtils.setFinalStatic(Build.class, "MODEL", "Test Model"); + } + + @After + public void after() { + TestUtils.setFinalStatic(Build.class, "BRAND", null); + TestUtils.setFinalStatic(Build.class, "MODEL", null); + } + + @Test + public void ctor_shouldInitializeFpsRangeWithHighestUpperValueFromRangeArray() { + FpsRangeFeature fpsRangeFeature = createTestInstance(); + assertEquals(13, (int) fpsRangeFeature.getValue().getUpper()); + } + + @Test + public void getDebugName_shouldReturnTheNameOfTheFeature() { + FpsRangeFeature fpsRangeFeature = createTestInstance(); + assertEquals("FpsRangeFeature", fpsRangeFeature.getDebugName()); + } + + @Test + public void getValue_shouldReturnHighestUpperRangeIfNotSet() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FpsRangeFeature fpsRangeFeature = createTestInstance(); + + assertEquals(13, (int) fpsRangeFeature.getValue().getUpper()); + } + + @Test + public void getValue_shouldEchoTheSetValue() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + FpsRangeFeature fpsRangeFeature = new FpsRangeFeature(mockCameraProperties); + @SuppressWarnings("unchecked") + Range expectedValue = mock(Range.class); + + fpsRangeFeature.setValue(expectedValue); + Range actualValue = fpsRangeFeature.getValue(); + + assertEquals(expectedValue, actualValue); + } + + @Test + public void checkIsSupported_shouldReturnTrue() { + FpsRangeFeature fpsRangeFeature = createTestInstance(); + assertTrue(fpsRangeFeature.checkIsSupported()); + } + + @Test + @SuppressWarnings("unchecked") + public void updateBuilder_shouldSetAeTargetFpsRange() { + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + FpsRangeFeature fpsRangeFeature = createTestInstance(); + + fpsRangeFeature.updateBuilder(mockBuilder); + + verify(mockBuilder).set(eq(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE), any(Range.class)); + } + + private static FpsRangeFeature createTestInstance() { + @SuppressWarnings("unchecked") + Range rangeOne = mock(Range.class); + @SuppressWarnings("unchecked") + Range rangeTwo = mock(Range.class); + @SuppressWarnings("unchecked") + Range rangeThree = mock(Range.class); + + when(rangeOne.getUpper()).thenReturn(11); + when(rangeTwo.getUpper()).thenReturn(12); + when(rangeThree.getUpper()).thenReturn(13); + + @SuppressWarnings("unchecked") + Range[] ranges = new Range[] {rangeOne, rangeTwo, rangeThree}; + + CameraProperties cameraProperties = mock(CameraProperties.class); + + when(cameraProperties.getControlAutoExposureAvailableTargetFpsRanges()).thenReturn(ranges); + + return new FpsRangeFeature(cameraProperties); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java new file mode 100644 index 000000000000..b89aad0f6773 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/noisereduction/NoiseReductionFeatureTest.java @@ -0,0 +1,150 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.noisereduction; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.camera2.CaptureRequest; +import android.os.Build.VERSION; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.utils.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class NoiseReductionFeatureTest { + @Before + public void before() { + // Make sure the VERSION.SDK_INT field returns 23, to allow using all available + // noise reduction modes in tests. + TestUtils.setFinalStatic(VERSION.class, "SDK_INT", 23); + } + + @After + public void after() { + // Make sure we reset the VERSION.SDK_INT field to it's original value. + TestUtils.setFinalStatic(VERSION.class, "SDK_INT", 0); + } + + @Test + public void getDebugName_shouldReturnTheNameOfTheFeature() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + assertEquals("NoiseReductionFeature", noiseReductionFeature.getDebugName()); + } + + @Test + public void getValue_shouldReturnFastIfNotSet() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + assertEquals(NoiseReductionMode.fast, noiseReductionFeature.getValue()); + } + + @Test + public void getValue_shouldEchoTheSetValue() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + NoiseReductionMode expectedValue = NoiseReductionMode.fast; + + noiseReductionFeature.setValue(expectedValue); + NoiseReductionMode actualValue = noiseReductionFeature.getValue(); + + assertEquals(expectedValue, actualValue); + } + + @Test + public void checkIsSupported_shouldReturnFalseWhenAvailableNoiseReductionModesIsNull() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(null); + + assertFalse(noiseReductionFeature.checkIsSupported()); + } + + @Test + public void + checkIsSupported_shouldReturnFalseWhenAvailableNoiseReductionModesReturnsAnEmptyArray() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {}); + + assertFalse(noiseReductionFeature.checkIsSupported()); + } + + @Test + public void + checkIsSupported_shouldReturnTrueWhenAvailableNoiseReductionModesReturnsAtLeastOneItem() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {1}); + + assertTrue(noiseReductionFeature.checkIsSupported()); + } + + @Test + public void updateBuilder_shouldReturnWhenCheckIsSupportedIsFalse() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {}); + + noiseReductionFeature.updateBuilder(mockBuilder); + + verify(mockBuilder, never()).set(any(), any()); + } + + @Test + public void updateBuilder_shouldSetNoiseReductionModeOffWhenOff() { + testUpdateBuilderWith(NoiseReductionMode.off, CaptureRequest.NOISE_REDUCTION_MODE_OFF); + } + + @Test + public void updateBuilder_shouldSetNoiseReductionModeFastWhenFast() { + testUpdateBuilderWith(NoiseReductionMode.fast, CaptureRequest.NOISE_REDUCTION_MODE_FAST); + } + + @Test + public void updateBuilder_shouldSetNoiseReductionModeHighQualityWhenHighQuality() { + testUpdateBuilderWith( + NoiseReductionMode.highQuality, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY); + } + + @Test + public void updateBuilder_shouldSetNoiseReductionModeMinimalWhenMinimal() { + testUpdateBuilderWith(NoiseReductionMode.minimal, CaptureRequest.NOISE_REDUCTION_MODE_MINIMAL); + } + + @Test + public void updateBuilder_shouldSetNoiseReductionModeZeroShutterLagWhenZeroShutterLag() { + testUpdateBuilderWith( + NoiseReductionMode.zeroShutterLag, CaptureRequest.NOISE_REDUCTION_MODE_ZERO_SHUTTER_LAG); + } + + private static void testUpdateBuilderWith(NoiseReductionMode mode, int expectedResult) { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + CaptureRequest.Builder mockBuilder = mock(CaptureRequest.Builder.class); + NoiseReductionFeature noiseReductionFeature = new NoiseReductionFeature(mockCameraProperties); + + when(mockCameraProperties.getAvailableNoiseReductionModes()).thenReturn(new int[] {1}); + + noiseReductionFeature.setValue(mode); + noiseReductionFeature.updateBuilder(mockBuilder); + verify(mockBuilder, times(1)).set(CaptureRequest.NOISE_REDUCTION_MODE, expectedResult); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java new file mode 100644 index 000000000000..957b57a66435 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/resolution/ResolutionFeatureTest.java @@ -0,0 +1,332 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.resolution; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import android.media.CamcorderProfile; +import android.media.EncoderProfiles; +import io.flutter.plugins.camera.CameraProperties; +import java.util.List; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +public class ResolutionFeatureTest { + private static final String cameraName = "1"; + private CamcorderProfile mockProfileLowLegacy; + private EncoderProfiles mockProfileLow; + private MockedStatic mockedStaticProfile; + + @Before + @SuppressWarnings("deprecation") + public void beforeLegacy() { + mockedStaticProfile = mockStatic(CamcorderProfile.class); + mockProfileLowLegacy = mock(CamcorderProfile.class); + CamcorderProfile mockProfileLegacy = mock(CamcorderProfile.class); + + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_HIGH)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_2160P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_1080P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_720P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_480P)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_QVGA)) + .thenReturn(true); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_LOW)) + .thenReturn(true); + + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_HIGH)) + .thenReturn(mockProfileLegacy); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_2160P)) + .thenReturn(mockProfileLegacy); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_1080P)) + .thenReturn(mockProfileLegacy); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)) + .thenReturn(mockProfileLegacy); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_480P)) + .thenReturn(mockProfileLegacy); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_QVGA)) + .thenReturn(mockProfileLegacy); + mockedStaticProfile + .when(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_LOW)) + .thenReturn(mockProfileLowLegacy); + } + + public void before() { + mockProfileLow = mock(EncoderProfiles.class); + EncoderProfiles mockProfile = mock(EncoderProfiles.class); + EncoderProfiles.VideoProfile mockVideoProfile = mock(EncoderProfiles.VideoProfile.class); + List mockVideoProfilesList = List.of(mockVideoProfile); + + mockedStaticProfile + .when(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_HIGH)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_2160P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_1080P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_720P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_480P)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_QVGA)) + .thenReturn(mockProfile); + mockedStaticProfile + .when(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_LOW)) + .thenReturn(mockProfileLow); + + when(mockProfile.getVideoProfiles()).thenReturn(mockVideoProfilesList); + when(mockVideoProfile.getHeight()).thenReturn(100); + when(mockVideoProfile.getWidth()).thenReturn(100); + } + + @After + public void after() { + mockedStaticProfile.reset(); + mockedStaticProfile.close(); + } + + @Test + public void getDebugName_shouldReturnTheNameOfTheFeature() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + assertEquals("ResolutionFeature", resolutionFeature.getDebugName()); + } + + @Test + public void getValue_shouldReturnInitialValueWhenNotSet() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + assertEquals(ResolutionPreset.max, resolutionFeature.getValue()); + } + + @Test + public void getValue_shouldEchoSetValue() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + resolutionFeature.setValue(ResolutionPreset.high); + + assertEquals(ResolutionPreset.high, resolutionFeature.getValue()); + } + + @Test + public void checkIsSupport_returnsTrue() { + CameraProperties mockCameraProperties = mock(CameraProperties.class); + ResolutionFeature resolutionFeature = + new ResolutionFeature(mockCameraProperties, ResolutionPreset.max, cameraName); + + assertTrue(resolutionFeature.checkIsSupported()); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void getBestAvailableCamcorderProfileForResolutionPreset_shouldFallThroughLegacy() { + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_HIGH)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_2160P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_1080P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_720P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_480P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_QVGA)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_LOW)) + .thenReturn(true); + + assertEquals( + mockProfileLowLegacy, + ResolutionFeature.getBestAvailableCamcorderProfileForResolutionPresetLegacy( + 1, ResolutionPreset.max)); + } + + @Config(minSdk = 31) + @Test + public void getBestAvailableCamcorderProfileForResolutionPreset_shouldFallThrough() { + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_HIGH)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_2160P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_1080P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_720P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_480P)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_QVGA)) + .thenReturn(false); + mockedStaticProfile + .when(() -> CamcorderProfile.hasProfile(1, CamcorderProfile.QUALITY_LOW)) + .thenReturn(true); + + assertEquals( + mockProfileLow, + ResolutionFeature.getBestAvailableCamcorderProfileForResolutionPreset( + 1, ResolutionPreset.max)); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetMaxLegacy() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.max); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Config(minSdk = 31) + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetMax() { + before(); + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.max); + + mockedStaticProfile.verify(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_720P)); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetUltraHighLegacy() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.ultraHigh); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Config(minSdk = 31) + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetUltraHigh() { + before(); + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.ultraHigh); + + mockedStaticProfile.verify(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_720P)); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetVeryHighLegacy() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.veryHigh); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Config(minSdk = 31) + @SuppressWarnings("deprecation") + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetVeryHigh() { + before(); + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.veryHigh); + + mockedStaticProfile.verify(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_720P)); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetHighLegacy() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.high); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_720P)); + } + + @Config(minSdk = 31) + @Test + public void computeBestPreviewSize_shouldUse720PWhenResolutionPresetHigh() { + before(); + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.high); + + mockedStaticProfile.verify(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_720P)); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void computeBestPreviewSize_shouldUse480PWhenResolutionPresetMediumLegacy() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.medium); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_480P)); + } + + @Config(minSdk = 31) + @Test + public void computeBestPreviewSize_shouldUse480PWhenResolutionPresetMedium() { + before(); + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.medium); + + mockedStaticProfile.verify(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_480P)); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void computeBestPreviewSize_shouldUseQVGAWhenResolutionPresetLowLegacy() { + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.low); + + mockedStaticProfile.verify(() -> CamcorderProfile.get(1, CamcorderProfile.QUALITY_QVGA)); + } + + @Config(minSdk = 31) + @Test + public void computeBestPreviewSize_shouldUseQVGAWhenResolutionPresetLow() { + before(); + ResolutionFeature.computeBestPreviewSize(1, ResolutionPreset.low); + + mockedStaticProfile.verify(() -> CamcorderProfile.getAll("1", CamcorderProfile.QUALITY_QVGA)); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java new file mode 100644 index 000000000000..82449a10188a --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/DeviceOrientationManagerTest.java @@ -0,0 +1,301 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.sensororientation; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.provider.Settings; +import android.view.Display; +import android.view.Surface; +import android.view.WindowManager; +import io.flutter.embedding.engine.systemchannels.PlatformChannel.DeviceOrientation; +import io.flutter.plugins.camera.DartMessenger; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +public class DeviceOrientationManagerTest { + private Activity mockActivity; + private DartMessenger mockDartMessenger; + private WindowManager mockWindowManager; + private Display mockDisplay; + private DeviceOrientationManager deviceOrientationManager; + + @Before + @SuppressWarnings("deprecation") + public void before() { + mockActivity = mock(Activity.class); + mockDartMessenger = mock(DartMessenger.class); + mockDisplay = mock(Display.class); + mockWindowManager = mock(WindowManager.class); + + when(mockActivity.getSystemService(Context.WINDOW_SERVICE)).thenReturn(mockWindowManager); + when(mockWindowManager.getDefaultDisplay()).thenReturn(mockDisplay); + + deviceOrientationManager = + DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 0); + } + + @Test + public void getVideoOrientation_whenNaturalScreenOrientationEqualsPortraitUp() { + int degreesPortraitUp = + deviceOrientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_UP); + int degreesPortraitDown = + deviceOrientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_DOWN); + int degreesLandscapeLeft = + deviceOrientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_LEFT); + int degreesLandscapeRight = + deviceOrientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_RIGHT); + + assertEquals(0, degreesPortraitUp); + assertEquals(90, degreesLandscapeLeft); + assertEquals(180, degreesPortraitDown); + assertEquals(270, degreesLandscapeRight); + } + + @Test + public void getVideoOrientation_whenNaturalScreenOrientationEqualsLandscapeLeft() { + DeviceOrientationManager orientationManager = + DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 90); + + int degreesPortraitUp = orientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_UP); + int degreesPortraitDown = + orientationManager.getVideoOrientation(DeviceOrientation.PORTRAIT_DOWN); + int degreesLandscapeLeft = + orientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_LEFT); + int degreesLandscapeRight = + orientationManager.getVideoOrientation(DeviceOrientation.LANDSCAPE_RIGHT); + + assertEquals(90, degreesPortraitUp); + assertEquals(180, degreesLandscapeLeft); + assertEquals(270, degreesPortraitDown); + assertEquals(0, degreesLandscapeRight); + } + + @Test + public void getVideoOrientation_shouldFallbackToSensorOrientationWhenOrientationIsNull() { + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + + int degrees = deviceOrientationManager.getVideoOrientation(null); + + assertEquals(90, degrees); + } + + @Test + 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); + + assertEquals(0, degreesPortraitUp); + assertEquals(90, degreesLandscapeRight); + assertEquals(180, degreesPortraitDown); + assertEquals(270, degreesLandscapeLeft); + } + + @Test + public void getPhotoOrientation_whenNaturalScreenOrientationEqualsLandscapeLeft() { + DeviceOrientationManager orientationManager = + DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 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); + + assertEquals(90, degreesPortraitUp); + assertEquals(180, degreesLandscapeRight); + assertEquals(270, degreesPortraitDown); + assertEquals(0, degreesLandscapeLeft); + } + + @Test + public void getPhotoOrientation_shouldFallbackToCurrentOrientationWhenOrientationIsNull() { + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + + int degrees = deviceOrientationManager.getPhotoOrientation(null); + + assertEquals(270, degrees); + } + + @Test + 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(0); + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + + deviceOrientationManager.handleUIOrientationChange(); + } + + verify(mockDartMessenger, times(1)) + .sendDeviceOrientationChangeEvent(DeviceOrientation.LANDSCAPE_LEFT); + } + + @Test + public void handleOrientationChange_shouldSendMessageWhenOrientationIsUpdated() { + DeviceOrientation previousOrientation = DeviceOrientation.PORTRAIT_UP; + DeviceOrientation newOrientation = DeviceOrientation.LANDSCAPE_LEFT; + + DeviceOrientationManager.handleOrientationChange( + newOrientation, previousOrientation, mockDartMessenger); + + verify(mockDartMessenger, times(1)).sendDeviceOrientationChangeEvent(newOrientation); + } + + @Test + public void handleOrientationChange_shouldNotSendMessageWhenOrientationIsNotUpdated() { + DeviceOrientation previousOrientation = DeviceOrientation.PORTRAIT_UP; + DeviceOrientation newOrientation = DeviceOrientation.PORTRAIT_UP; + + DeviceOrientationManager.handleOrientationChange( + newOrientation, previousOrientation, mockDartMessenger); + + verify(mockDartMessenger, never()).sendDeviceOrientationChangeEvent(any()); + } + + @Test + public void getUIOrientation() { + // Orientation portrait and rotation of 0 should translate to "PORTRAIT_UP". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + DeviceOrientation uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_UP, uiOrientation); + + // Orientation portrait and rotation of 90 should translate to "PORTRAIT_UP". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_90); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_UP, uiOrientation); + + // Orientation portrait and rotation of 180 should translate to "PORTRAIT_DOWN". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_180); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_DOWN, uiOrientation); + + // Orientation portrait and rotation of 270 should translate to "PORTRAIT_DOWN". + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_270); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_DOWN, uiOrientation); + + // Orientation landscape and rotation of 0 should translate to "LANDSCAPE_LEFT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_LEFT, uiOrientation); + + // Orientation landscape and rotation of 90 should translate to "LANDSCAPE_LEFT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_90); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_LEFT, uiOrientation); + + // Orientation landscape and rotation of 180 should translate to "LANDSCAPE_RIGHT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_180); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_RIGHT, uiOrientation); + + // Orientation landscape and rotation of 270 should translate to "LANDSCAPE_RIGHT". + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_270); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.LANDSCAPE_RIGHT, uiOrientation); + + // Orientation undefined should default to "PORTRAIT_UP". + setUpUIOrientationMocks(Configuration.ORIENTATION_UNDEFINED, Surface.ROTATION_0); + uiOrientation = deviceOrientationManager.getUIOrientation(); + assertEquals(DeviceOrientation.PORTRAIT_UP, uiOrientation); + } + + @Test + public void getDeviceDefaultOrientation() { + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + int orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_180); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_90); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_270); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_0); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_180); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_LANDSCAPE, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_90); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_LANDSCAPE, Surface.ROTATION_270); + orientation = deviceOrientationManager.getDeviceDefaultOrientation(); + assertEquals(Configuration.ORIENTATION_PORTRAIT, orientation); + } + + @Test + public void calculateSensorOrientation() { + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + DeviceOrientation orientation = deviceOrientationManager.calculateSensorOrientation(0); + assertEquals(DeviceOrientation.PORTRAIT_UP, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + orientation = deviceOrientationManager.calculateSensorOrientation(90); + assertEquals(DeviceOrientation.LANDSCAPE_LEFT, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + orientation = deviceOrientationManager.calculateSensorOrientation(180); + assertEquals(DeviceOrientation.PORTRAIT_DOWN, orientation); + + setUpUIOrientationMocks(Configuration.ORIENTATION_PORTRAIT, Surface.ROTATION_0); + orientation = deviceOrientationManager.calculateSensorOrientation(270); + assertEquals(DeviceOrientation.LANDSCAPE_RIGHT, orientation); + } + + private void setUpUIOrientationMocks(int orientation, int rotation) { + Resources mockResources = mock(Resources.class); + Configuration mockConfiguration = mock(Configuration.class); + + when(mockDisplay.getRotation()).thenReturn(rotation); + + mockConfiguration.orientation = orientation; + when(mockActivity.getResources()).thenReturn(mockResources); + when(mockResources.getConfiguration()).thenReturn(mockConfiguration); + } + + @Test + public void getDisplayTest() { + Display display = deviceOrientationManager.getDisplay(); + + assertEquals(mockDisplay, display); + } +} diff --git a/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java new file mode 100644 index 000000000000..2c3a5ab46634 --- /dev/null +++ b/packages/camera/camera/android/src/test/java/io/flutter/plugins/camera/features/sensororientation/SensorOrientationFeatureTest.java @@ -0,0 +1,125 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.camera.features.sensororientation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.hardware.camera2.CameraMetadata; +import io.flutter.embedding.engine.systemchannels.PlatformChannel.DeviceOrientation; +import io.flutter.plugins.camera.CameraProperties; +import io.flutter.plugins.camera.DartMessenger; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +public class SensorOrientationFeatureTest { + private MockedStatic mockedStaticDeviceOrientationManager; + private Activity mockActivity; + private CameraProperties mockCameraProperties; + private DartMessenger mockDartMessenger; + private DeviceOrientationManager mockDeviceOrientationManager; + + @Before + public void before() { + mockedStaticDeviceOrientationManager = mockStatic(DeviceOrientationManager.class); + mockActivity = mock(Activity.class); + mockCameraProperties = mock(CameraProperties.class); + mockDartMessenger = mock(DartMessenger.class); + mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + + when(mockCameraProperties.getSensorOrientation()).thenReturn(0); + when(mockCameraProperties.getLensFacing()).thenReturn(CameraMetadata.LENS_FACING_BACK); + + mockedStaticDeviceOrientationManager + .when(() -> DeviceOrientationManager.create(mockActivity, mockDartMessenger, false, 0)) + .thenReturn(mockDeviceOrientationManager); + } + + @After + public void after() { + mockedStaticDeviceOrientationManager.close(); + } + + @Test + public void ctor_shouldStartDeviceOrientationManager() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + verify(mockDeviceOrientationManager, times(1)).start(); + } + + @Test + public void getDebugName_shouldReturnTheNameOfTheFeature() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertEquals("SensorOrientationFeature", sensorOrientationFeature.getDebugName()); + } + + @Test + public void getValue_shouldReturnNullIfNotSet() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertEquals(0, (int) sensorOrientationFeature.getValue()); + } + + @Test + public void getValue_shouldEchoSetValue() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + sensorOrientationFeature.setValue(90); + + assertEquals(90, (int) sensorOrientationFeature.getValue()); + } + + @Test + public void checkIsSupport_returnsTrue() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertTrue(sensorOrientationFeature.checkIsSupported()); + } + + @Test + public void getDeviceOrientationManager_shouldReturnInitializedDartOrientationManagerInstance() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + assertEquals( + mockDeviceOrientationManager, sensorOrientationFeature.getDeviceOrientationManager()); + } + + @Test + public void lockCaptureOrientation_shouldLockToSpecifiedOrientation() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + sensorOrientationFeature.lockCaptureOrientation(DeviceOrientation.PORTRAIT_DOWN); + + assertEquals( + DeviceOrientation.PORTRAIT_DOWN, sensorOrientationFeature.getLockedCaptureOrientation()); + } + + @Test + public void unlockCaptureOrientation_shouldSetLockToNull() { + SensorOrientationFeature sensorOrientationFeature = + new SensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); + + sensorOrientationFeature.unlockCaptureOrientation(); + + assertNull(sensorOrientationFeature.getLockedCaptureOrientation()); + } +} diff --git a/packages/camera/camera/android/src/test/resources/robolectric.properties b/packages/camera/camera/android/src/test/resources/robolectric.properties new file mode 100644 index 000000000000..90fbd74370a7 --- /dev/null +++ b/packages/camera/camera/android/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=30 \ No newline at end of file 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/ios/RunnerTests/CameraExposureTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraExposureTests.m new file mode 100644 index 000000000000..ee43d3f155f4 --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/CameraExposureTests.m @@ -0,0 +1,55 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import camera; +@import XCTest; +@import AVFoundation; +#import + +@interface FLTCam : NSObject + +- (void)setExposurePointWithResult:(FlutterResult)result x:(double)x y:(double)y; +@end + +@interface CameraExposureTests : XCTestCase +@property(readonly, nonatomic) FLTCam *camera; +@property(readonly, nonatomic) id mockDevice; +@property(readonly, nonatomic) id mockUIDevice; +@end + +@implementation CameraExposureTests + +- (void)setUp { + _camera = [[FLTCam alloc] init]; + _mockDevice = OCMClassMock([AVCaptureDevice class]); + _mockUIDevice = OCMPartialMock([UIDevice currentDevice]); +} + +- (void)tearDown { + [_mockDevice stopMocking]; + [_mockUIDevice stopMocking]; +} + +- (void)testSetExpsourePointWithResult_SetsExposurePointOfInterest { + // UI is currently in landscape left orientation + OCMStub([(UIDevice *)_mockUIDevice orientation]).andReturn(UIDeviceOrientationLandscapeLeft); + // Exposure point of interest is supported + OCMStub([_mockDevice isExposurePointOfInterestSupported]).andReturn(true); + // Set mock device as the current capture device + [_camera setValue:_mockDevice forKey:@"captureDevice"]; + + // Run test + [_camera + setExposurePointWithResult:^void(id _Nullable result) { + } + x:1 + y:1]; + + // Verify the focus point of interest has been set + OCMVerify([_mockDevice setExposurePointOfInterest:CGPointMake(1, 1)]); +} + +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraMethodChannelTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraMethodChannelTests.m new file mode 100644 index 000000000000..254a33c7ee4e --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/CameraMethodChannelTests.m @@ -0,0 +1,48 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import camera; +@import camera.Test; +@import XCTest; +@import AVFoundation; +#import +#import "MockFLTThreadSafeFlutterResult.h" + +@interface CameraMethodChannelTests : XCTestCase +@end + +@implementation CameraMethodChannelTests + +- (void)testCreate_ShouldCallResultOnMainThread { + CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil]; + + XCTestExpectation *expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + // Set up mocks for initWithCameraName method + id avCaptureDeviceInputMock = OCMClassMock([AVCaptureDeviceInput class]); + OCMStub([avCaptureDeviceInputMock deviceInputWithDevice:[OCMArg any] error:[OCMArg anyObjectRef]]) + .andReturn([AVCaptureInput alloc]); + + id avCaptureSessionMock = OCMClassMock([AVCaptureSession class]); + OCMStub([avCaptureSessionMock alloc]).andReturn(avCaptureSessionMock); + OCMStub([avCaptureSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); + + MockFLTThreadSafeFlutterResult *resultObject = + [[MockFLTThreadSafeFlutterResult alloc] initWithExpectation:expectation]; + + // Set up method call + FlutterMethodCall *call = [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"resolutionPreset" : @"medium", @"enableAudio" : @(1)}]; + + [camera handleMethodCallAsync:call result:resultObject]; + + // Verify the result + NSDictionary *dictionaryResult = (NSDictionary *)resultObject.receivedResult; + XCTAssertNotNil(dictionaryResult); + XCTAssert([[dictionaryResult allKeys] containsObject:@"cameraId"]); +} + +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m b/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m new file mode 100644 index 000000000000..eb6c0079322c --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/CameraPreviewPauseTests.m @@ -0,0 +1,42 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import camera; +@import XCTest; +@import AVFoundation; +#import +#import "MockFLTThreadSafeFlutterResult.h" + +@interface FLTCam : NSObject +@property(assign, nonatomic) BOOL isPreviewPaused; + +- (void)pausePreviewWithResult:(FLTThreadSafeFlutterResult *)result; + +- (void)resumePreviewWithResult:(FLTThreadSafeFlutterResult *)result; +@end + +@interface CameraPreviewPauseTests : XCTestCase +@end + +@implementation CameraPreviewPauseTests + +- (void)testPausePreviewWithResult_shouldPausePreview { + FLTCam *camera = [[FLTCam alloc] init]; + MockFLTThreadSafeFlutterResult *resultObject = [[MockFLTThreadSafeFlutterResult alloc] init]; + + [camera pausePreviewWithResult:resultObject]; + XCTAssertTrue(camera.isPreviewPaused); +} + +- (void)testResumePreviewWithResult_shouldResumePreview { + FLTCam *camera = [[FLTCam alloc] init]; + MockFLTThreadSafeFlutterResult *resultObject = [[MockFLTThreadSafeFlutterResult alloc] init]; + + [camera resumePreviewWithResult:resultObject]; + XCTAssertFalse(camera.isPreviewPaused); +} + +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/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/ios/RunnerTests/MockFLTThreadSafeFlutterResult.h b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.h new file mode 100644 index 000000000000..8685f3fd610b --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.h @@ -0,0 +1,25 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef MockFLTThreadSafeFlutterResult_h +#define MockFLTThreadSafeFlutterResult_h + +/** + * Extends FLTThreadSafeFlutterResult to give tests the ability to wait on the result and + * read the received result. + */ +@interface MockFLTThreadSafeFlutterResult : FLTThreadSafeFlutterResult +@property(readonly, nonatomic, nonnull) XCTestExpectation *expectation; +@property(nonatomic, nullable) id receivedResult; + +/** + * Initializes the MockFLTThreadSafeFlutterResult with an expectation. + * + * The expectation is fullfilled when a result is called allowing tests to await the result in an + * asynchronous manner. + */ +- (nonnull instancetype)initWithExpectation:(nonnull XCTestExpectation *)expectation; +@end + +#endif /* MockFLTThreadSafeFlutterResult_h */ diff --git a/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.m b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.m new file mode 100644 index 000000000000..da2fc2d936ba --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/MockFLTThreadSafeFlutterResult.m @@ -0,0 +1,27 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import camera; +@import XCTest; + +#import "MockFLTThreadSafeFlutterResult.h" + +@implementation MockFLTThreadSafeFlutterResult + +- (instancetype)initWithExpectation:(XCTestExpectation *)expectation { + self = [super init]; + _expectation = expectation; + return self; +} + +- (void)sendSuccessWithData:(id)data { + self.receivedResult = data; + [self.expectation fulfill]; +} + +- (void)sendSuccess { + self.receivedResult = nil; + [self.expectation fulfill]; +} +@end diff --git a/packages/camera/camera/example/ios/RunnerTests/ThreadSafeFlutterResultTests.m b/packages/camera/camera/example/ios/RunnerTests/ThreadSafeFlutterResultTests.m new file mode 100644 index 000000000000..8cd4b8bc8c2a --- /dev/null +++ b/packages/camera/camera/example/ios/RunnerTests/ThreadSafeFlutterResultTests.m @@ -0,0 +1,122 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import camera; +@import XCTest; + +@interface ThreadSafeFlutterResultTests : XCTestCase +@end + +@implementation ThreadSafeFlutterResultTests +- (void)testAsyncSendSuccess_ShouldCallResultOnMainThread { + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert(NSThread.isMainThread); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendSuccess]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSyncSendSuccess_ShouldCallResultOnMainThread { + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert(NSThread.isMainThread); + [expectation fulfill]; + }]; + [threadSafeFlutterResult sendSuccess]; + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendNotImplemented_ShouldSendNotImplementedToFlutterResult { + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert([result isKindOfClass:FlutterMethodNotImplemented.class]); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendNotImplemented]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendErrorDetails_ShouldSendErrorToFlutterResult { + NSString* errorCode = @"errorCode"; + NSString* errorMessage = @"message"; + NSString* errorDetails = @"error details"; + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert([result isKindOfClass:FlutterError.class]); + FlutterError* error = (FlutterError*)result; + XCTAssertEqualObjects(error.code, errorCode); + XCTAssertEqualObjects(error.message, errorMessage); + XCTAssertEqualObjects(error.details, errorDetails); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendErrorWithCode:errorCode message:errorMessage details:errorDetails]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendNSError_ShouldSendErrorToFlutterResult { + NSError* originalError = [[NSError alloc] initWithDomain:NSURLErrorDomain code:404 userInfo:nil]; + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssert([result isKindOfClass:FlutterError.class]); + FlutterError* error = (FlutterError*)result; + NSString* constructedErrorCode = + [NSString stringWithFormat:@"Error %d", (int)originalError.code]; + XCTAssertEqualObjects(error.code, constructedErrorCode); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendError:originalError]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} + +- (void)testSendResult_ShouldSendResultToFlutterResult { + NSString* resultData = @"resultData"; + XCTestExpectation* expectation = + [[XCTestExpectation alloc] initWithDescription:@"Result finished"]; + + FLTThreadSafeFlutterResult* threadSafeFlutterResult = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id _Nullable result) { + XCTAssertEqualObjects(result, resultData); + [expectation fulfill]; + }]; + dispatch_queue_t dispatchQueue = dispatch_queue_create("test dispatchqueue", NULL); + dispatch_async(dispatchQueue, ^{ + [threadSafeFlutterResult sendSuccessWithData:resultData]; + }); + + [self waitForExpectations:[NSArray arrayWithObject:expectation] timeout:1]; +} +@end diff --git a/packages/camera/camera/example/web/favicon.png b/packages/camera/camera/example/web/favicon.png new file mode 100644 index 000000000000..8aaa46ac1ae2 Binary files /dev/null and b/packages/camera/camera/example/web/favicon.png differ diff --git a/packages/camera/camera/example/web/icons/Icon-192.png b/packages/camera/camera/example/web/icons/Icon-192.png new file mode 100644 index 000000000000..b749bfef0747 Binary files /dev/null and b/packages/camera/camera/example/web/icons/Icon-192.png differ diff --git a/packages/camera/camera/example/web/icons/Icon-512.png b/packages/camera/camera/example/web/icons/Icon-512.png new file mode 100644 index 000000000000..88cfd48dff11 Binary files /dev/null and b/packages/camera/camera/example/web/icons/Icon-512.png differ diff --git a/packages/camera/camera/example/web/index.html b/packages/camera/camera/example/web/index.html new file mode 100644 index 000000000000..2a3117d29362 --- /dev/null +++ b/packages/camera/camera/example/web/index.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + Camera Web Example + + + + + + + + + + \ No newline at end of file diff --git a/packages/camera/camera/example/web/manifest.json b/packages/camera/camera/example/web/manifest.json new file mode 100644 index 000000000000..5fe0e048afe6 --- /dev/null +++ b/packages/camera/camera/example/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "camera example", + "short_name": "camera", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "An example of the camera on the web.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/packages/camera/camera/ios/Classes/CameraPlugin.modulemap b/packages/camera/camera/ios/Classes/CameraPlugin.modulemap new file mode 100644 index 000000000000..30afa91bdda2 --- /dev/null +++ b/packages/camera/camera/ios/Classes/CameraPlugin.modulemap @@ -0,0 +1,10 @@ +framework module camera { + umbrella header "camera-umbrella.h" + + export * + module * { export * } + + explicit module Test { + header "CameraPlugin_Test.h" + } +} diff --git a/packages/camera/camera/ios/Classes/CameraPlugin_Test.h b/packages/camera/camera/ios/Classes/CameraPlugin_Test.h new file mode 100644 index 000000000000..afbf6864a1f8 --- /dev/null +++ b/packages/camera/camera/ios/Classes/CameraPlugin_Test.h @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// This header is available in the Test module. Import via "@import camera.Test;" + +#import +#import + +/// Methods exposed for unit testing. +@interface CameraPlugin () + +/// Inject @p FlutterTextureRegistry and @p FlutterBinaryMessenger for unit testing. +- (instancetype)initWithRegistry:(NSObject *)registry + messenger:(NSObject *)messenger + NS_DESIGNATED_INITIALIZER; + +/// Hide the default public constructor. +- (instancetype)init NS_UNAVAILABLE; + +/// Handles `FlutterMethodCall`s and ensures result is send on the main dispatch queue. +/// +/// @param call The method call command object. +/// @param result A wrapper around the `FlutterResult` callback which ensures the callback is called +/// on the main dispatch queue. +- (void)handleMethodCallAsync:(FlutterMethodCall *)call result:(FLTThreadSafeFlutterResult *)result; + +/// Called by the @c NSNotificationManager each time the device's orientation is changed. +/// +/// @param notification @c NSNotification instance containing a reference to the `UIDevice` object +/// that triggered the orientation change. +- (void)orientationChanged:(NSNotification *)notification; + +@end diff --git a/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.h b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.h new file mode 100644 index 000000000000..f290ca0fcd05 --- /dev/null +++ b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.h @@ -0,0 +1,51 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +/** + * Wrapper for FlutterResult that always delivers the result on the main thread. + */ +@interface FLTThreadSafeFlutterResult : NSObject + +/** + * Gets the original FlutterResult object wrapped by this FLTThreadSafeFlutterResult instance. + */ +@property(readonly, nonatomic, nonnull) FlutterResult flutterResult; + +/** + * Initializes with a FlutterResult object. + * @param result The FlutterResult object that the result will be given to. + */ +- (nonnull instancetype)initWithResult:(nonnull FlutterResult)result; + +/** + * Sends a successful result without any data. + */ +- (void)sendSuccess; + +/** + * Sends a successful result with data. + * @param data Result data that is send to the Flutter Dart side. + */ +- (void)sendSuccessWithData:(nonnull id)data; + +/** + * Sends an NSError as result + * @param error Error that will be send as FlutterError. + */ +- (void)sendError:(nonnull NSError*)error; + +/** + * Sends a FlutterError as result. + */ +- (void)sendErrorWithCode:(nonnull NSString*)code + message:(nullable NSString*)message + details:(nullable id)details; + +/** + * Sends FlutterMethodNotImplemented as result. + */ +- (void)sendNotImplemented; +@end diff --git a/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.m b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.m new file mode 100644 index 000000000000..caa4788d8dc8 --- /dev/null +++ b/packages/camera/camera/ios/Classes/FLTThreadSafeFlutterResult.m @@ -0,0 +1,58 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTThreadSafeFlutterResult.h" +#import + +@implementation FLTThreadSafeFlutterResult { +} + +- (id)initWithResult:(FlutterResult)result { + self = [super init]; + if (!self) { + return nil; + } + _flutterResult = result; + return self; +} + +- (void)sendSuccess { + [self send:nil]; +} + +- (void)sendSuccessWithData:(id)data { + [self send:data]; +} + +- (void)sendError:(NSError*)error { + [self sendErrorWithCode:[NSString stringWithFormat:@"Error %d", (int)error.code] + message:error.localizedDescription + details:error.domain]; +} + +- (void)sendErrorWithCode:(NSString*)code + message:(NSString* _Nullable)message + details:(id _Nullable)details { + FlutterError* flutterError = [FlutterError errorWithCode:code message:message details:details]; + [self send:flutterError]; +} + +- (void)sendNotImplemented { + [self send:FlutterMethodNotImplemented]; +} + +/** + * Sends result to flutterResult on the main thread. + */ +- (void)send:(id _Nullable)result { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + self->_flutterResult(result); + }); + } else { + _flutterResult(result); + } +} + +@end diff --git a/packages/camera/camera/ios/Classes/camera-umbrella.h b/packages/camera/camera/ios/Classes/camera-umbrella.h new file mode 100644 index 000000000000..b0fd493b24df --- /dev/null +++ b/packages/camera/camera/ios/Classes/camera-umbrella.h @@ -0,0 +1,10 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import + +FOUNDATION_EXPORT double cameraVersionNumber; +FOUNDATION_EXPORT const unsigned char cameraVersionString[]; diff --git a/packages/google_maps_flutter/google_maps_flutter/AUTHORS b/packages/camera/camera_web/AUTHORS similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter/AUTHORS rename to packages/camera/camera_web/AUTHORS diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md new file mode 100644 index 000000000000..dd9225f48ff4 --- /dev/null +++ b/packages/camera/camera_web/CHANGELOG.md @@ -0,0 +1,12 @@ +## 0.2.1+1 + +* Update usage documentation. + +## 0.2.1 + +* Add video recording functionality. +* Fix cameraNotReadable error that prevented access to the camera on some Android devices. + +## 0.2.0 + +* Initial release, adapted from the Flutter [I/O Photobooth](https://photobooth.flutter.dev/) project. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/LICENSE b/packages/camera/camera_web/LICENSE similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter_web/LICENSE rename to packages/camera/camera_web/LICENSE diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md new file mode 100644 index 000000000000..04bf665c1039 --- /dev/null +++ b/packages/camera/camera_web/README.md @@ -0,0 +1,112 @@ +# Camera Web Plugin + +The web implementation of [`camera`][camera]. + +*Note*: This plugin is under development. See [missing implementation](#missing-implementation). + +## Usage + +### Depend on the package + +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). +Broadly speaking, this means that you need to serve your web application over HTTPS +(or `localhost` for local development). For insecure contexts +`CameraPlatform.availableCameras` might throw a `CameraException` with the +`permissionDenied` error code. + +### Device orientation + +The device orientation implementation is backed by [`Screen Orientation Web API`](https://www.w3.org/TR/screen-orientation/) +with the following [browser support](https://caniuse.com/screen-orientation): + +![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): + +![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 (blob) and can 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)); +} +``` + +### Video recording + +The video recording implementation is backed by [MediaRecorder Web API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder) with the following [browser support](https://caniuse.com/mdn-api_mediarecorder): + +![Data on support for the MediaRecorder feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/mediarecorder.png). + +A video is recorded in one of the following video MIME types: +- video/webm (e.g. on Chrome or Firefox) +- video/mp4 (e.g. on Safari) + +Pausing, resuming or stopping the video recording throws a `PlatformException` with the `videoRecordingNotStarted` error code if the video recording was not started. + +For the browsers that do not support the video recording: +- `CameraPlatform.startVideoRecording` throws a `PlatformException` with the `notSupported` error code. + +## Missing implementation + +The web implementation of [`camera`][camera] is missing the following features: +- Exposure mode, point and offset +- Focus mode and point +- Sensor orientation +- Image format group +- Streaming of frames + + +[camera]: https://pub.dev/packages/camera 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..a298b57dfd7f --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart @@ -0,0 +1,178 @@ +// Copyright 2013 The Flutter 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('videoRecordingNotStarted', (tester) async { + expect( + CameraErrorCode.videoRecordingNotStarted.toString(), + equals('videoRecordingNotStarted'), + ); + }); + + 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..3a25e33c5398 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -0,0 +1,1678 @@ +// Copyright 2013 The Flutter 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/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 a 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 a 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('video recording', () { + const supportedVideoType = 'video/webm'; + + late MediaRecorder mediaRecorder; + + bool isVideoTypeSupported(String type) => type == supportedVideoType; + + setUp(() { + mediaRecorder = MockMediaRecorder(); + + when(() => mediaRecorder.onError) + .thenAnswer((_) => const Stream.empty()); + }); + + group('startVideoRecording', () { + testWidgets( + 'creates a media recorder ' + 'with appropriate options', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + expect( + camera.mediaRecorder!.stream, + equals(camera.stream), + ); + + expect( + camera.mediaRecorder!.mimeType, + equals(supportedVideoType), + ); + + expect( + camera.mediaRecorder!.state, + equals('recording'), + ); + }); + + testWidgets('listens to the media recorder data events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + verify( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).called(1); + }); + + testWidgets('listens to the media recorder stop events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + verify( + () => mediaRecorder.addEventListener('stop', any()), + ).called(1); + }); + + testWidgets('starts a video recording', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + verify(mediaRecorder.start).called(1); + }); + + testWidgets( + 'starts a video recording ' + 'with maxVideoDuration', (tester) async { + const maxVideoDuration = Duration(hours: 1); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + + verify(() => mediaRecorder.start(maxVideoDuration.inMilliseconds)) + .called(1); + }); + + group('throws a CameraWebException', () { + testWidgets( + 'with notSupported error ' + 'when maxVideoDuration is 0 milliseconds or less', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + expect( + () => camera.startVideoRecording(maxVideoDuration: Duration.zero), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported, + ), + ), + ); + }); + + testWidgets( + 'with notSupported error ' + 'when no video types are supported', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..isVideoTypeSupported = (type) => false; + + await camera.initialize(); + await camera.play(); + + expect( + camera.startVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported, + ), + ), + ); + }); + }); + }); + + group('pauseVideoRecording', () { + testWidgets('pauses a video recording', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..mediaRecorder = mediaRecorder; + + await camera.pauseVideoRecording(); + + verify(mediaRecorder.pause).called(1); + }); + + testWidgets( + 'throws a CameraWebException ' + 'with videoRecordingNotStarted error ' + 'if the video recording was not started', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.pauseVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('resumeVideoRecording', () { + testWidgets('resumes a video recording', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..mediaRecorder = mediaRecorder; + + await camera.resumeVideoRecording(); + + verify(mediaRecorder.resume).called(1); + }); + + testWidgets( + 'throws a CameraWebException ' + 'with videoRecordingNotStarted error ' + 'if the video recording was not started', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.resumeVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('stopVideoRecording', () { + testWidgets( + 'stops a video recording and ' + 'returns the captured file ' + 'based on all video data parts', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + late void Function(Event) videoDataAvailableListener; + late void Function(Event) videoRecordingStoppedListener; + + when( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).thenAnswer((invocation) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + + Blob? finalVideo; + List? videoParts; + camera.blobBuilder = (blobs, videoType) { + videoParts = [...blobs]; + finalVideo = Blob(blobs, videoType); + return finalVideo!; + }; + + await camera.startVideoRecording(); + final videoFileFuture = camera.stopVideoRecording(); + + final capturedVideoPartOne = Blob([]); + final capturedVideoPartTwo = Blob([]); + + final capturedVideoParts = [ + capturedVideoPartOne, + capturedVideoPartTwo, + ]; + + videoDataAvailableListener + ..call(FakeBlobEvent(capturedVideoPartOne)) + ..call(FakeBlobEvent(capturedVideoPartTwo)); + + videoRecordingStoppedListener.call(Event('stop')); + + final videoFile = await videoFileFuture; + + verify(mediaRecorder.stop).called(1); + + expect( + videoFile, + isNotNull, + ); + + expect( + videoFile.mimeType, + equals(supportedVideoType), + ); + + expect( + videoFile.name, + equals(finalVideo.hashCode.toString()), + ); + + expect( + videoParts, + equals(capturedVideoParts), + ); + }); + + testWidgets( + 'throws a CameraWebException ' + 'with videoRecordingNotStarted error ' + 'if the video recording was not started', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.stopVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('on video data available', () { + late void Function(Event) videoDataAvailableListener; + + setUp(() { + when( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).thenAnswer((invocation) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + }); + + testWidgets( + 'stops a video recording ' + 'if maxVideoDuration is given and ' + 'the recording was not stopped manually', (tester) async { + const maxVideoDuration = Duration(hours: 1); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + + when(() => mediaRecorder.state).thenReturn('recording'); + + videoDataAvailableListener.call(FakeBlobEvent(Blob([]))); + + await Future.microtask(() {}); + + verify(mediaRecorder.stop).called(1); + }); + }); + + group('on video recording stopped', () { + late void Function(Event) videoRecordingStoppedListener; + + setUp(() { + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + }); + + testWidgets('stops listening to the media recorder data events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + verify( + () => mediaRecorder.removeEventListener('dataavailable', any()), + ).called(1); + }); + + testWidgets('stops listening to the media recorder stop events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + verify( + () => mediaRecorder.removeEventListener('stop', any()), + ).called(1); + }); + + testWidgets('stops listening to the media recorder errors', + (tester) async { + final onErrorStreamController = StreamController(); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + when(() => mediaRecorder.onError) + .thenAnswer((_) => onErrorStreamController.stream); + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + expect( + onErrorStreamController.hasListener, + isFalse, + ); + }); + }); + }); + + group('dispose', () { + testWidgets('resets the video element\'s source', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect(camera.videoElement.srcObject, isNull); + }); + + testWidgets('closes the onEnded stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.onEndedController.isClosed, + isTrue, + ); + }); + + testWidgets('closes the onVideoRecordedEvent stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.videoRecorderController.isClosed, + isTrue, + ); + }); + + testWidgets('closes the onVideoRecordingError stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.videoRecordingErrorController.isClosed, + isTrue, + ); + }); + }); + + group('events', () { + group('onVideoRecordedEvent', () { + testWidgets( + 'emits a VideoRecordedEvent ' + 'when a video recording is created', (tester) async { + const maxVideoDuration = Duration(hours: 1); + const supportedVideoType = 'video/webm'; + + final mediaRecorder = MockMediaRecorder(); + when(() => mediaRecorder.onError) + .thenAnswer((_) => const Stream.empty()); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = (type) => type == 'video/webm'; + + await camera.initialize(); + await camera.play(); + + late void Function(Event) videoDataAvailableListener; + late void Function(Event) videoRecordingStoppedListener; + + when( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).thenAnswer((invocation) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + + final streamQueue = StreamQueue(camera.onVideoRecordedEvent); + + await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + + Blob? finalVideo; + camera.blobBuilder = (blobs, videoType) { + finalVideo = Blob(blobs, videoType); + return finalVideo!; + }; + + videoDataAvailableListener.call(FakeBlobEvent(Blob([]))); + videoRecordingStoppedListener.call(Event('stop')); + + expect( + await streamQueue.next, + equals( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.file, + 'file', + isA() + .having( + (f) => f.mimeType, + 'mimeType', + supportedVideoType, + ) + .having( + (f) => f.name, + 'name', + finalVideo.hashCode.toString(), + ), + ) + .having( + (e) => e.maxVideoDuration, + 'maxVideoDuration', + maxVideoDuration, + ), + ), + ); + + await streamQueue.cancel(); + }); + }); + + group('onEnded', () { + testWidgets( + 'emits the default video track ' + '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(); + }); + }); + + group('onVideoRecordingError', () { + testWidgets( + 'emits an ErrorEvent ' + 'when the media recorder fails ' + 'when recording a video', (tester) async { + final mediaRecorder = MockMediaRecorder(); + final errorController = StreamController(); + + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + )..mediaRecorder = mediaRecorder; + + when(() => mediaRecorder.onError) + .thenAnswer((_) => errorController.stream); + + final streamQueue = StreamQueue(camera.onVideoRecordingError); + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + final errorEvent = ErrorEvent('type'); + errorController.add(errorEvent); + + expect( + await streamQueue.next, + equals(errorEvent), + ); + + await streamQueue.cancel(); + }); + }); + }); + }); +} 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..9749559ed8c6 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -0,0 +1,2946 @@ +// Copyright 2013 The Flutter 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( + 'releases the camera stream ' + 'used to request video and audio permissions', (tester) async { + final videoTrack = MockMediaStreamTrack(); + + var videoTrackStopped = false; + when(videoTrack.stop).thenAnswer((_) { + videoTrackStopped = true; + }); + + when( + () => cameraService.getMediaStreamForOptions( + CameraOptions( + audio: AudioConstraints(enabled: true), + ), + ), + ).thenAnswer( + (_) => Future.value( + FakeMediaStream([videoTrack]), + ), + ); + + final _ = await CameraPlatform.instance.availableCameras(); + + expect(videoTrackStopped, isTrue); + }); + + testWidgets( + 'gets a video stream ' + 'for a video input device', (tester) async { + 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(), + ), + ), + ); + }); + }); + }); + + group('startVideoRecording', () { + late Camera camera; + + setUp(() { + camera = MockCamera(); + + when(camera.startVideoRecording).thenAnswer((_) async {}); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => const Stream.empty()); + }); + + testWidgets('starts a video recording', (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + + verify(camera.startVideoRecording).called(1); + }); + + testWidgets('listens to the onVideoRecordingError stream', + (tester) async { + final videoRecordingErrorController = StreamController(); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + + expect( + videoRecordingErrorController.hasListener, + isTrue, + ); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when startVideoRecording throws DomException', + (tester) async { + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.startVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when startVideoRecording throws CameraWebException', + (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.startVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); + }); + + group('stopVideoRecording', () { + testWidgets('stops a video recording', (tester) async { + final camera = MockCamera(); + final capturedVideo = MockXFile(); + + when(camera.stopVideoRecording) + .thenAnswer((_) => Future.value(capturedVideo)); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final video = + await CameraPlatform.instance.stopVideoRecording(cameraId); + + verify(camera.stopVideoRecording).called(1); + + expect(video, capturedVideo); + }); + + testWidgets('stops listening to the onVideoRecordingError stream', + (tester) async { + final camera = MockCamera(); + final videoRecordingErrorController = StreamController(); + + when(camera.startVideoRecording).thenAnswer((_) async => {}); + + when(camera.stopVideoRecording) + .thenAnswer((_) => Future.value(MockXFile())); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + final _ = await CameraPlatform.instance.stopVideoRecording(cameraId); + + expect( + videoRecordingErrorController.hasListener, + isFalse, + ); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when stopVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.stopVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when stopVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.stopVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); + }); + + group('pauseVideoRecording', () { + testWidgets('pauses a video recording', (tester) async { + final camera = MockCamera(); + + when(camera.pauseVideoRecording).thenAnswer((_) async {}); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.pauseVideoRecording(cameraId); + + verify(camera.pauseVideoRecording).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when pauseVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.pauseVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when pauseVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.pauseVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); + }); + + group('resumeVideoRecording', () { + testWidgets('resumes a video recording', (tester) async { + final camera = MockCamera(); + + when(camera.resumeVideoRecording).thenAnswer((_) async {}); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.resumeVideoRecording(cameraId); + + verify(camera.resumeVideoRecording).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when resumeVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.resumeVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when resumeVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.resumeVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); + }); + + group('setFlashMode', () { + 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; + late StreamController videoRecordingErrorController; + + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); + videoRecordingErrorController = 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); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + when(camera.startVideoRecording).thenAnswer((_) async {}); + }); + + 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); + }); + + testWidgets('cancels the camera video recording error subscriptions', + (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.startVideoRecording(cameraId); + await CameraPlatform.instance.dispose(cameraId); + + expect(videoRecordingErrorController.hasListener, isFalse); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + '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; + late StreamController videoRecordingErrorController; + + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); + videoRecordingErrorController = 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); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + when(() => camera.startVideoRecording()).thenAnswer((_) async => {}); + }); + + testWidgets( + 'onCameraInitialized emits a CameraInitializedEvent ' + 'on initializeCamera', (tester) async { + // Mock the camera to use a blank video stream of size 1280x720. + const videoSize = Size(1280, 720); + + videoElement = getVideoElementWithBlankStream(videoSize); + + when( + () => 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( + 'emits a CameraErrorEvent ' + 'on startVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => const Stream.empty()); + + when( + () => camera.startVideoRecording( + maxVideoDuration: any(named: 'maxVideoDuration'), + ), + ).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on the camera video recording error event', (tester) async { + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.startVideoRecording(cameraId); + + final errorEvent = FakeErrorEvent('type', 'message'); + + videoRecordingErrorController.add(errorEvent); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${errorEvent.type}, error message: ${errorEvent.message}.', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on stopVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.stopVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on pauseVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.pauseVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on resumeVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.resumeVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + }); + + testWidgets('onVideoRecordedEvent emits a VideoRecordedEvent', + (tester) async { + final camera = MockCamera(); + final capturedVideo = MockXFile(); + final stream = Stream.value( + VideoRecordedEvent(cameraId, capturedVideo, Duration.zero)); + when(() => camera.onVideoRecordedEvent).thenAnswer((_) => stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final streamQueue = + StreamQueue(CameraPlatform.instance.onVideoRecordedEvent(cameraId)); + + expect( + await streamQueue.next, + equals( + VideoRecordedEvent(cameraId, capturedVideo, Duration.zero), + ), + ); + }); + + 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..77e9077356f7 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -0,0 +1,172 @@ +// Copyright 2013 The Flutter 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 {} + +class MockMediaRecorder extends Mock implements MediaRecorder {} + +/// 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, + ); + } +} + +/// A fake [BlobEvent] that returns the provided blob [data]. +class FakeBlobEvent extends Fake implements BlobEvent { + FakeBlobEvent(this._blob); + + final Blob? _blob; + + @override + Blob? get data => _blob; +} + +/// A fake [DomException] that returns the provided error [_name] and [_message]. +class FakeErrorEvent extends Fake implements ErrorEvent { + FakeErrorEvent( + String type, [ + String? message, + ]) : _type = type, + _message = message; + + final String _type; + final String? _message; + + @override + String get type => _type; + + @override + String? get message => _message; +} + +/// Returns a video element with a blank stream of size [videoSize]. +/// +/// Can be used to mock a video stream: +/// ```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/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml b/packages/camera/camera_web/example/pubspec.yaml similarity index 51% rename from packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml rename to packages/camera/camera_web/example/pubspec.yaml index b0ac9910afc9..1e075712325e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/pubspec.yaml +++ b/packages/camera/camera_web/example/pubspec.yaml @@ -1,22 +1,18 @@ -name: google_maps_flutter_web_integration_tests +name: camera_web_integration_tests publish_to: none -# Tests require flutter beta or greater to run. environment: sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.1.0" + flutter: ">=2.0.0" dependencies: - google_maps_flutter_web: - path: ../ flutter: sdk: flutter dev_dependencies: - build_runner: ^1.11.0 - google_maps: ^5.1.0 - http: ^0.13.0 - mockito: ^5.0.0 + mocktail: ^0.1.4 + camera_web: + path: ../ flutter_driver: sdk: flutter flutter_test: 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/google_maps_flutter/google_maps_flutter/example/test_driver/integration_test.dart b/packages/camera/camera_web/example/test_driver/integration_test.dart similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter/example/test_driver/integration_test.dart rename to packages/camera/camera_web/example/test_driver/integration_test.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html b/packages/camera/camera_web/example/web/index.html similarity index 54% rename from packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html rename to packages/camera/camera_web/example/web/index.html index 3121d189b913..f3c6a5e8a8e3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/web/index.html +++ b/packages/camera/camera_web/example/web/index.html @@ -1,12 +1,10 @@ - + 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..cf0187057188 --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -0,0 +1,635 @@ +// Copyright 2013 The Flutter 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 stream can be played/stopped by calling [play]/[stop], +/// may capture a picture by calling [takePicture] or capture a video +/// by calling [startVideoRecording], [pauseVideoRecording], +/// [resumeVideoRecording] or [stopVideoRecording]. +/// +/// The camera zoom may be adjusted with [setZoomLevel]. The provided +/// zoom level must be a value in the range of [getMinZoomLevel] to +/// [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 => onEndedController.stream; + + /// The stream controller for the [onEnded] stream. + @visibleForTesting + final onEndedController = StreamController.broadcast(); + + StreamSubscription? _onEndedSubscription; + + /// The stream of the camera video recording errors. + /// + /// This occurs when the video recording is not allowed or an unsupported + /// codec is used. + /// + /// MediaRecorder.error: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/error_event + Stream get onVideoRecordingError => + videoRecordingErrorController.stream; + + /// The stream controller for the [onVideoRecordingError] stream. + @visibleForTesting + final videoRecordingErrorController = + StreamController.broadcast(); + + StreamSubscription? _onVideoRecordingErrorSubscription; + + /// The camera flash mode. + @visibleForTesting + FlashMode? flashMode; + + /// 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; + + /// The recorder used to record a video from the camera. + @visibleForTesting + html.MediaRecorder? mediaRecorder; + + /// Whether the video of the given type is supported. + @visibleForTesting + bool Function(String) isVideoTypeSupported = + html.MediaRecorder.isTypeSupported; + + /// The list of consecutive video data files recorded with [mediaRecorder]. + List _videoData = []; + + /// Completes when the video recording is stopped/finished. + Completer? _videoAvailableCompleter; + + /// A data listener fired when a new part of video data is available. + void Function(html.Event)? _videoDataAvailableListener; + + /// A listener fired when a video recording is stopped. + void Function(html.Event)? _videoRecordingStoppedListener; + + /// A builder to merge a list of blobs into a single blob. + @visibleForTesting + html.Blob Function(List blobs, String type) blobBuilder = + (blobs, type) => html.Blob(blobs, type); + + /// The stream that emits a [VideoRecordedEvent] when a video recording is created. + Stream get onVideoRecordedEvent => + videoRecorderController.stream; + + /// The stream controller for the [onVideoRecordedEvent] stream. + @visibleForTesting + final StreamController videoRecorderController = + StreamController.broadcast(); + + /// Initializes the camera stream displayed in the [videoElement]. + /// Registers the camera view with [textureId] under [_getViewType] type. + /// Emits the camera default video track on the [onEnded] stream when it ends. + 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 _) { + onEndedController.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) { + onEndedController.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); + + /// Starts a new video recording using [html.MediaRecorder]. + /// + /// Throws a [CameraWebException] if the provided maximum video duration is invalid + /// or the browser does not support any of the available video mime types + /// from [_videoMimeType]. + Future startVideoRecording({Duration? maxVideoDuration}) async { + if (maxVideoDuration != null && maxVideoDuration.inMilliseconds <= 0) { + throw CameraWebException( + textureId, + CameraErrorCode.notSupported, + 'The maximum video duration must be greater than 0 milliseconds.', + ); + } + + mediaRecorder ??= html.MediaRecorder(videoElement.srcObject!, { + 'mimeType': _videoMimeType, + }); + + _videoAvailableCompleter = Completer(); + + _videoDataAvailableListener = + (event) => _onVideoDataAvailable(event, maxVideoDuration); + + _videoRecordingStoppedListener = + (event) => _onVideoRecordingStopped(event, maxVideoDuration); + + mediaRecorder!.addEventListener( + 'dataavailable', + _videoDataAvailableListener, + ); + + mediaRecorder!.addEventListener( + 'stop', + _videoRecordingStoppedListener, + ); + + _onVideoRecordingErrorSubscription = + mediaRecorder!.onError.listen((html.Event event) { + final error = event as html.ErrorEvent; + if (error != null) { + videoRecordingErrorController.add(error); + } + }); + + if (maxVideoDuration != null) { + mediaRecorder!.start(maxVideoDuration.inMilliseconds); + } else { + // Don't pass the null duration as that will fire a `dataavailable` event directly. + mediaRecorder!.start(); + } + } + + void _onVideoDataAvailable( + html.Event event, [ + Duration? maxVideoDuration, + ]) { + final blob = (event as html.BlobEvent).data; + + // Append the recorded part of the video to the list of all video data files. + if (blob != null) { + _videoData.add(blob); + } + + // Stop the recorder if the video has a maxVideoDuration + // and the recording was not stopped manually. + if (maxVideoDuration != null && mediaRecorder!.state == 'recording') { + mediaRecorder!.stop(); + } + } + + Future _onVideoRecordingStopped( + html.Event event, [ + Duration? maxVideoDuration, + ]) async { + if (_videoData.isNotEmpty) { + // Concatenate all video data files into a single blob. + final videoType = _videoData.first.type; + final videoBlob = blobBuilder(_videoData, videoType); + + // Create a file containing the video blob. + final file = XFile( + html.Url.createObjectUrl(videoBlob), + mimeType: _videoMimeType, + name: videoBlob.hashCode.toString(), + ); + + // Emit an event containing the recorded video file. + videoRecorderController.add( + VideoRecordedEvent(this.textureId, file, maxVideoDuration), + ); + + _videoAvailableCompleter?.complete(file); + } + + // Clean up the media recorder with its event listeners and video data. + mediaRecorder!.removeEventListener( + 'dataavailable', + _videoDataAvailableListener, + ); + + mediaRecorder!.removeEventListener( + 'stop', + _videoDataAvailableListener, + ); + + await _onVideoRecordingErrorSubscription?.cancel(); + + mediaRecorder = null; + _videoDataAvailableListener = null; + _videoRecordingStoppedListener = null; + _videoData.clear(); + } + + /// Pauses the current video recording. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future pauseVideoRecording() async { + if (mediaRecorder == null) { + throw _videoRecordingNotStartedException; + } + mediaRecorder!.pause(); + } + + /// Resumes the current video recording. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future resumeVideoRecording() async { + if (mediaRecorder == null) { + throw _videoRecordingNotStartedException; + } + mediaRecorder!.resume(); + } + + /// Stops the video recording and returns the captured video file. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future stopVideoRecording() async { + if (mediaRecorder == null || _videoAvailableCompleter == null) { + throw _videoRecordingNotStartedException; + } + + mediaRecorder!.stop(); + + return _videoAvailableCompleter!.future; + } + + /// Disposes the camera by stopping the camera stream, + /// the video recording and reloading the camera source. + Future dispose() async { + // Stop the camera stream. + stop(); + + await videoRecorderController.close(); + mediaRecorder = null; + _videoDataAvailableListener = null; + + // Reset the [videoElement] to its initial state. + videoElement + ..srcObject = null + ..load(); + + await _onEndedSubscription?.cancel(); + _onEndedSubscription = null; + await onEndedController.close(); + + await _onVideoRecordingErrorSubscription?.cancel(); + _onVideoRecordingErrorSubscription = null; + await videoRecordingErrorController.close(); + } + + /// Returns the first supported video mime type (amongst mp4 and webm) + /// to use when recording a video. + /// + /// Throws a [CameraWebException] if the browser does not support + /// any of the available video mime types. + String get _videoMimeType { + const types = [ + 'video/mp4', + 'video/webm', + ]; + + return types.firstWhere( + (type) => isVideoTypeSupported(type), + orElse: () => throw CameraWebException( + textureId, + CameraErrorCode.notSupported, + 'The browser does not support any of the following video types: ${types.join(',')}.', + ), + ); + } + + CameraWebException get _videoRecordingNotStartedException => + CameraWebException( + textureId, + CameraErrorCode.videoRecordingNotStarted, + 'The video recorder is uninitialized. The recording might not have been started. Make sure to call `startVideoRecording` first.', + ); + + /// Applies default styles to the video [element]. + void _applyDefaultVideoStyles(html.VideoElement element) { + final isBackCamera = getLensDirection() == CameraLensDirection.back; + + // 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..0021ee47cbde --- /dev/null +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -0,0 +1,672 @@ +// Copyright 2013 The Flutter 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 = + >{}; + + final _cameraVideoRecordingErrorSubscriptions = + >{}; + + /// 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. + final cameraStream = await _cameraService.getMediaStreamForOptions( + CameraOptions( + audio: AudioConstraints(enabled: true), + ), + ); + + // Release the camera stream used to request video and audio permissions. + cameraStream.getVideoTracks().forEach((videoTrack) => videoTrack.stop()); + + // Request available media devices. + final devices = await mediaDevices.enumerateDevices(); + + // 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) { + return getCamera(cameraId).onVideoRecordedEvent; + } + + @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() async { + // This is a no-op as it is not required for the web. + } + + @override + Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) { + try { + final camera = getCamera(cameraId); + + // Add camera's video recording errors to the camera events stream. + // The error event fires when the video recording is not allowed or an unsupported + // codec is used. + _cameraVideoRecordingErrorSubscriptions[cameraId] = + camera.onVideoRecordingError.listen((html.ErrorEvent errorEvent) { + cameraEventStreamController.add( + CameraErrorEvent( + cameraId, + 'Error code: ${errorEvent.type}, error message: ${errorEvent.message}.', + ), + ); + }); + + return camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } + } + + @override + Future stopVideoRecording(int cameraId) async { + try { + final videoRecording = await getCamera(cameraId).stopVideoRecording(); + await _cameraVideoRecordingErrorSubscriptions[cameraId]?.cancel(); + return videoRecording; + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } + } + + @override + Future pauseVideoRecording(int cameraId) { + try { + return getCamera(cameraId).pauseVideoRecording(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } + } + + @override + Future resumeVideoRecording(int cameraId) { + try { + return getCamera(cameraId).resumeVideoRecording(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } + } + + @override + 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(); + await _cameraVideoRecordingErrorSubscriptions[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/google_maps_flutter/google_maps_flutter_web/lib/src/shims/dart_ui.dart b/packages/camera/camera_web/lib/src/shims/dart_ui.dart similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter_web/lib/src/shims/dart_ui.dart rename to packages/camera/camera_web/lib/src/shims/dart_ui.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/shims/dart_ui_fake.dart b/packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter_web/lib/src/shims/dart_ui_fake.dart rename to packages/camera/camera_web/lib/src/shims/dart_ui_fake.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/shims/dart_ui_real.dart b/packages/camera/camera_web/lib/src/shims/dart_ui_real.dart similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter_web/lib/src/shims/dart_ui_real.dart rename to packages/camera/camera_web/lib/src/shims/dart_ui_real.dart 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..f70925b4bede --- /dev/null +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -0,0 +1,95 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +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'); + + /// The video recording was not started. + static const CameraErrorCode videoRecordingNotStarted = + CameraErrorCode._('videoRecordingNotStarted'); + + /// 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..f37500ad6e22 --- /dev/null +++ b/packages/camera/camera_web/pubspec.yaml @@ -0,0 +1,30 @@ +name: camera_web +description: A Flutter plugin for getting information about and controlling the camera on Web. +repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera_web +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 +version: 0.2.1+1 + +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 + stream_transform: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + pedantic: ^1.11.1 diff --git a/packages/google_maps_flutter/google_maps_flutter_web/test/README.md b/packages/camera/camera_web/test/README.md similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter_web/test/README.md rename to packages/camera/camera_web/test/README.md 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/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/connectivity/connectivity/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/espresso/android/lint-baseline.xml b/packages/espresso/android/lint-baseline.xml new file mode 100644 index 000000000000..19b349f044bf --- /dev/null +++ b/packages/espresso/android/lint-baseline.xml @@ -0,0 +1,389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/flutter_plugin_android_lifecycle/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/google_maps_flutter/analysis_options.yaml b/packages/google_maps_flutter/analysis_options.yaml deleted file mode 100644 index cda4f6e153e6..000000000000 --- a/packages/google_maps_flutter/analysis_options.yaml +++ /dev/null @@ -1 +0,0 @@ -include: ../../analysis_options_legacy.yaml diff --git a/packages/google_maps_flutter/android/build.gradle b/packages/google_maps_flutter/android/build.gradle new file mode 100644 index 000000000000..dcbc7cec4173 --- /dev/null +++ b/packages/google_maps_flutter/android/build.gradle @@ -0,0 +1,64 @@ +group 'io.flutter.plugins.googlemaps' +version '1.0-SNAPSHOT' + +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.2' + } +} + +rootProject.allprojects { + repositories { + google() + jcenter() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 28 + + defaultConfig { + minSdkVersion 16 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + lintOptions { + disable 'InvalidPackage' + } + + dependencies { + implementation 'com.google.android.gms:play-services-maps:17.0.0' + } +} + +// TODO(cyanglaz): Remove this hack once androidx.lifecycle is included on stable. https://github.com/flutter/flutter/issues/42348 +afterEvaluate { + def containsEmbeddingDependencies = false + for (def configuration : configurations.all) { + for (def dependency : configuration.dependencies) { + if (dependency.group == 'io.flutter' && + dependency.name.startsWith('flutter_embedding') && + dependency.isTransitive()) + { + containsEmbeddingDependencies = true + break + } + } + } + if (!containsEmbeddingDependencies) { + android { + dependencies { + def lifecycle_version = "1.1.1" + compileOnly "android.arch.lifecycle:runtime:$lifecycle_version" + compileOnly "android.arch.lifecycle:common:$lifecycle_version" + compileOnly "android.arch.lifecycle:common-java8:$lifecycle_version" + } + } + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index 5ba399311661..936cc24715aa 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,161 +1,6 @@ -## NEXT - -* Add iOS unit and UI integration test targets. - -## 2.0.6 - -* Migrate maven repo from jcenter to mavenCentral. - -## 2.0.5 - -* Google Maps requires at least Android SDK 20. - -## 2.0.4 - -* Unpin iOS GoogleMaps pod dependency version. - -## 2.0.3 - -* Fix incorrect typecast in TileOverlay example. -* Fix english wording in instructions. - -## 2.0.2 - -* Update flutter\_plugin\_android\_lifecycle dependency to 2.0.1 to fix an R8 issue - on some versions. - -## 2.0.1 - -* Update platform\_plugin\_interface version requirement. - -## 2.0.0 - -* Migrate to null-safety -* BREAKING CHANGE: Passing an unknown map object ID (e.g., MarkerId) to a - method, it will throw an `UnknownMapObjectIDError`. Previously it would - either silently do nothing, or throw an error trying to call a function on - `null`, depneding on the method. - -## 1.2.0 - -* Support custom tiles. - -## 1.1.1 - -* Fix in example app to properly place polyline at initial camera position. - -## 1.1.0 - -* Add support for holes in Polygons. - -## 1.0.10 - -* Update the example app: remove the deprecated `RaisedButton` and `FlatButton` widgets. - -## 1.0.9 - -* Fix outdated links across a number of markdown files ([#3276](https://github.com/flutter/plugins/pull/3276)) - -## 1.0.8 - -* Update Flutter SDK constraint. - -## 1.0.7 - -* Android: Handle deprecation & unchecked warning as error. - -## 1.0.6 - -* Update Dart SDK constraint in example. -* Remove unused `test` dependency in the example app. - -## 1.0.5 - -Overhaul lifecycle management in GoogleMapsPlugin. - -GoogleMapController is now uniformly driven by implementing `DefaultLifecycleObserver`. That observer is registered to a lifecycle from one of three sources: - -1. For v2 plugin registration, `GoogleMapsPlugin` obtains the lifecycle via `ActivityAware` methods. -2. For v1 plugin registration, if the activity implements `LifecycleOwner`, it's lifecycle is used directly. -3. For v1 plugin registration, if the activity does not implement `LifecycleOwner`, a proxy lifecycle is created and driven via `ActivityLifecycleCallbacks`. - -## 1.0.4 - -* Cleanup of Android code: -* A few minor formatting changes and additions of `@Nullable` annotations. -* Removed pass-through of `activityHashCode` to `GoogleMapController`. -* Replaced custom lifecycle state ints with `androidx.lifecycle.Lifecycle.State` enum. -* Fixed a bug where the Lifecycle object was being leaked `onDetachFromActivity`, by nulling out the field. -* Moved GoogleMapListener to its own file. Declaring multiple top level classes in the same file is discouraged. - -## 1.0.3 - -* Update android compileSdkVersion to 29. - -## 1.0.2 - -* Remove `io.flutter.embedded_views_preview` requirement from readme. - -## 1.0.1 - -* Fix headline in the readme. - -## 1.0.0 - Out of developer preview 🎉. - -* Bump the minimal Flutter SDK to 1.22 where platform views are out of developer preview and performing better on iOS. Flutter 1.22 no longer requires adding the `io.flutter.embedded_views_preview` to `Info.plist` in iOS. - -## 0.5.33 - -* Keep handling deprecated Android v1 classes for backward compatibility. - -## 0.5.32 - -* Fix typo in google_maps_flutter/example/map_ui.dart. - -## 0.5.31 - -* Geodesic Polyline support for iOS - -## 0.5.30 - -* Add a `dispose` method to the controller to let the native side know that we're done with said controller. -* Call `controller.dispose()` from the `dispose` method of the `GoogleMap` widget. - -## 0.5.29+1 - -* (ios) Pin dependency on GoogleMaps pod to `< 3.10`, to address https://github.com/flutter/flutter/issues/63447 - -## 0.5.29 - -* Pass a constant `_web_only_mapCreationId` to `platform.buildView`, so web can return a cached widget DOM when flutter attempts to repaint there. -* Modify some examples slightly so they're more web-friendly. - -## 0.5.28+2 - -* Move test introduced in #2449 to its right location. - -## 0.5.28+1 - -* Android: Make sure map view only calls onDestroy once. -* Android: Fix a memory leak regression caused in `0.5.26+4`. - -## 0.5.28 - -* Android: Add liteModeEnabled option. - -## 0.5.27+3 - -* iOS: Update the gesture recognizer blocking policy to "WaitUntilTouchesEnded", which fixes the camera idle callback not triggered issue. -* Update the min flutter version to 1.16.3. -* Skip `testTakeSnapshot` test on Android. - -## 0.5.27+2 - -* Update lower bound of dart dependency to 2.1.0. - ## 0.5.27+1 -* Remove endorsement of `web` platform, it's not ready yet. +* Adds support for heatmaps in google_maps_flutter. ## 0.5.27 diff --git a/packages/google_maps_flutter/google_maps_flutter/LICENSE b/packages/google_maps_flutter/google_maps_flutter/LICENSE index c6823b81eb84..8940a4be1b58 100644 --- a/packages/google_maps_flutter/google_maps_flutter/LICENSE +++ b/packages/google_maps_flutter/google_maps_flutter/LICENSE @@ -1,25 +1,27 @@ -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. +// Copyright 2018 The Chromium 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/google_maps_flutter/google_maps_flutter/README.md b/packages/google_maps_flutter/google_maps_flutter/README.md index c80fcb949dad..4f206721995a 100644 --- a/packages/google_maps_flutter/google_maps_flutter/README.md +++ b/packages/google_maps_flutter/google_maps_flutter/README.md @@ -1,12 +1,26 @@ -# Google Maps for Flutter +# Google Maps for Flutter (Developers Preview) -[![pub package](https://img.shields.io/pub/v/google_maps_flutter.svg)](https://pub.dev/packages/google_maps_flutter) +[![pub package](https://img.shields.io/pub/v/google_maps_flutter.svg)](https://pub.dartlang.org/packages/google_maps_flutter) A Flutter plugin that provides a [Google Maps](https://developers.google.com/maps/) widget. +## Developers Preview Status +The plugin relies on Flutter's new mechanism for embedding Android and iOS views. +As that mechanism is currently in a developers preview, this plugin should also be +considered a developers preview. + +Known issues are tagged with the [platform-views](https://github.com/flutter/flutter/labels/a%3A%20platform-views) and/or [maps](https://github.com/flutter/flutter/labels/p%3A%20maps) labels. + +To use this plugin on iOS you need to opt-in for the embedded views preview by +adding a boolean property to the app's `Info.plist` file, with the key `io.flutter.embedded_views_preview` +and the value `YES`. + +The API exposed by this plugin is not yet stable, and we expect some breaking changes to land soon. + + ## Usage -To use this plugin, add `google_maps_flutter` as a [dependency in your pubspec.yaml file](https://flutter.dev/docs/development/platform-integration/platform-channels). +To use this plugin, add `google_maps_flutter` as a [dependency in your pubspec.yaml file](https://flutter.io/platform-plugins/). ## Getting Started @@ -21,23 +35,11 @@ To use this plugin, add `google_maps_flutter` as a [dependency in your pubspec.y * To enable Google Maps for iOS, select "Maps SDK for iOS" in the "Additional APIs" section, then select "ENABLE". * Make sure the APIs you enabled are under the "Enabled APIs" section. -For more details, see [Getting started with Google Maps Platform](https://developers.google.com/maps/gmp-get-started). +* You can also find detailed steps to get start with Google Maps Platform [here](https://developers.google.com/maps/gmp-get-started). ### Android -1. Set the `minSdkVersion` in `android/app/build.gradle`: - -```groovy -android { - defaultConfig { - minSdkVersion 20 - } -} -``` - -This means that app will only be available for users that run Android SDK 20 or higher. - -2. Specify your API key in the application manifest `android/app/src/main/AndroidManifest.xml`: +Specify your API key in the application manifest `android/app/src/main/AndroidManifest.xml`: ```xml data = toList(o); switch (toString(data.get(0))) { @@ -79,8 +77,7 @@ private static BitmapDescriptor getBitmapFromBytes(List data) { } } else { throw new IllegalArgumentException( - "fromBytes should have exactly one argument, interpretTileOverlayOptions the bytes. Got: " - + data.size()); + "fromBytes should have exactly one argument, the bytes. Got: " + data.size()); } } @@ -193,26 +190,12 @@ static Object polylineIdToJson(String polylineId) { return data; } - static Object circleIdToJson(String circleId) { - if (circleId == null) { + static Object heatmapIdToJson(String heatmapId) { + if (heatmapId == null) { return null; } final Map data = new HashMap<>(1); - data.put("circleId", circleId); - return data; - } - - static Map tileOverlayArgumentsToJson( - String tileOverlayId, int x, int y, int zoom) { - - if (tileOverlayId == null) { - return null; - } - final Map data = new HashMap<>(4); - data.put("tileOverlayId", tileOverlayId); - data.put("x", x); - data.put("y", y); - data.put("zoom", zoom); + data.put("heatmapId", heatmapId); return data; } @@ -226,9 +209,8 @@ static LatLng toLatLng(Object o) { } static Point toPoint(Object o) { - Object x = toMap(o).get("x"); - Object y = toMap(o).get("y"); - return new Point((int) x, (int) y); + Map screenCoordinate = (Map) o; + return new Point(screenCoordinate.get("x"), screenCoordinate.get("y")); } static Map pointToJson(Point point) { @@ -254,18 +236,6 @@ private static List toList(Object o) { return (Map) o; } - private static Map toObjectMap(Object o) { - Map hashMap = new HashMap<>(); - Map map = (Map) o; - for (Object key : map.keySet()) { - Object object = map.get(key); - if (object != null) { - hashMap.put((String) key, object); - } - } - return hashMap; - } - private static float toFractionalPixels(Object o, float density) { return toFloat(o) * density; } @@ -348,10 +318,6 @@ static void interpretGoogleMapOptions(Object o, GoogleMapOptionsSink sink) { if (zoomGesturesEnabled != null) { sink.setZoomGesturesEnabled(toBoolean(zoomGesturesEnabled)); } - final Object liteModeEnabled = data.get("liteModeEnabled"); - if (liteModeEnabled != null) { - sink.setLiteModeEnabled(toBoolean(liteModeEnabled)); - } final Object myLocationEnabled = data.get("myLocationEnabled"); if (myLocationEnabled != null) { sink.setMyLocationEnabled(toBoolean(myLocationEnabled)); @@ -409,7 +375,7 @@ static String interpretMarkerOptions(Object o, MarkerOptionsSink sink) { final Object infoWindow = data.get("infoWindow"); if (infoWindow != null) { - interpretInfoWindowOptions(sink, toObjectMap(infoWindow)); + interpretInfoWindowOptions(sink, (Map) infoWindow); } final Object position = data.get("position"); if (position != null) { @@ -484,10 +450,6 @@ static String interpretPolygonOptions(Object o, PolygonOptionsSink sink) { if (points != null) { sink.setPoints(toPoints(points)); } - final Object holes = data.get("holes"); - if (holes != null) { - sink.setHoles(toHoles(holes)); - } final String polygonId = (String) data.get("polygonId"); if (polygonId == null) { throw new IllegalArgumentException("polygonId was null"); @@ -592,25 +554,109 @@ static String interpretCircleOptions(Object o, CircleOptionsSink sink) { } } + /** + * Converts the heatmap from JSON to a java object. + * + * @param Object heatmapObject + * @param HeatmapOptionsSink sink + * @return String The id of the heatmap + */ + static String interpretHeatmapOptions(Object heatmapObject, HeatmapOptionsSink sink) { + final Map data = toMap(heatmapObject); + final Object points = data.get("points"); + if (points != null) { + sink.setPoints(toWeightedPoints(points)); + } + final Object gradient = data.get("gradient"); + if (gradient != null) { + sink.setGradient(toGradient(gradient)); + } + final Object opacity = data.get("opacity"); + if (opacity != null) { + sink.setOpacity(toDouble(opacity)); + } + final Object radius = data.get("radius"); + if (radius != null) { + sink.setRadius(toInt(radius)); + } + final Object fadeIn = data.get("fadeIn"); + if (fadeIn != null) { + sink.setFadeIn(toBoolean(fadeIn)); + } + final Object transparency = data.get("transparency"); + if (transparency != null) { + sink.setTransparency(toFloat(transparency)); + } + final Object visible = data.get("visible"); + if (visible != null) { + sink.setVisible(toBoolean(visible)); + } + final Object zIndex = data.get("zIndex"); + if (zIndex != null) { + sink.setZIndex(toFloat(zIndex)); + } + final String heatmapId = (String) data.get("heatmapId"); + if (heatmapId == null) { + throw new IllegalArgumentException("heatmapId was null"); + } else { + return heatmapId; + } + } + private static List toPoints(Object o) { final List data = toList(o); final List points = new ArrayList<>(data.size()); - for (Object rawPoint : data) { - final List point = toList(rawPoint); + for (Object ob : data) { + final List point = toList(ob); points.add(new LatLng(toFloat(point.get(0)), toFloat(point.get(1)))); } return points; } - private static List> toHoles(Object o) { + private static List toWeightedPoints(Object weightedPointsObject) { + final List data = toList(weightedPointsObject); + final List points = new ArrayList<>(data.size()); + + for (Object ob : data) { + final List weightedPoint = toList(ob); + final List point = toList(weightedPoint.get(0)); + points.add( + new WeightedLatLng( + new LatLng(toFloat(point.get(0)), toFloat(point.get(1))), + toInt(weightedPoint.get(1)))); + } + return points; + } + + private static Gradient toGradient(Object o) { + final List data = toList(o); + int[] colors = toIntArray(data.get(0)); + float[] startPoints = toFloatArray(data.get(1)); + int colorMapSize = toInt(data.get(2)); + return new Gradient(colors, startPoints, colorMapSize); + } + + private static int[] toIntArray(Object o) { final List data = toList(o); - final List> holes = new ArrayList<>(data.size()); + final int[] ints = new int[data.size()]; - for (Object rawHole : data) { - holes.add(toPoints(rawHole)); + int index = 0; + for (Object ob : data) { + ints[index++] = toInt(ob); + } + return ints; + } + + private static float[] toFloatArray(Object o) { + final List data = toList(o); + final float[] floats = new float[data.size()]; + + int index = 0; + for (Object ob : data) { + floats[index++] = toFloat(ob); } - return holes; + return floats; } private static List toPattern(Object o) { @@ -661,39 +707,4 @@ private static Cap toCap(Object o) { throw new IllegalArgumentException("Cannot interpret " + o + " as Cap"); } } - - static String interpretTileOverlayOptions(Map data, TileOverlaySink sink) { - final Object fadeIn = data.get("fadeIn"); - if (fadeIn != null) { - sink.setFadeIn(toBoolean(fadeIn)); - } - final Object transparency = data.get("transparency"); - if (transparency != null) { - sink.setTransparency(toFloat(transparency)); - } - final Object zIndex = data.get("zIndex"); - if (zIndex != null) { - sink.setZIndex(toFloat(zIndex)); - } - final Object visible = data.get("visible"); - if (visible != null) { - sink.setVisible(toBoolean(visible)); - } - final String tileOverlayId = (String) data.get("tileOverlayId"); - if (tileOverlayId == null) { - throw new IllegalArgumentException("tileOverlayId was null"); - } else { - return tileOverlayId; - } - } - - static Tile interpretTile(Map data) { - int width = toInt(data.get("width")); - int height = toInt(data.get("height")); - byte[] dataArray = null; - if (data.get("data") != null) { - dataArray = (byte[]) data.get("data"); - } - return new Tile(width, height, dataArray); - } } diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java index ad5179a69a45..4a49b1382c40 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapBuilder.java @@ -1,17 +1,19 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package io.flutter.plugins.googlemaps; +import android.app.Application; import android.content.Context; import android.graphics.Rect; +import androidx.lifecycle.Lifecycle; import com.google.android.gms.maps.GoogleMapOptions; import com.google.android.gms.maps.model.CameraPosition; import com.google.android.gms.maps.model.LatLngBounds; import io.flutter.plugin.common.BinaryMessenger; -import java.util.List; -import java.util.Map; +import io.flutter.plugin.common.PluginRegistry; +import java.util.concurrent.atomic.AtomicInteger; class GoogleMapBuilder implements GoogleMapOptionsSink { private final GoogleMapOptions options = new GoogleMapOptions(); @@ -24,17 +26,30 @@ class GoogleMapBuilder implements GoogleMapOptionsSink { private Object initialMarkers; private Object initialPolygons; private Object initialPolylines; + private Object initialHeatmaps; private Object initialCircles; - private List> initialTileOverlays; private Rect padding = new Rect(0, 0, 0, 0); GoogleMapController build( int id, Context context, + AtomicInteger state, BinaryMessenger binaryMessenger, - LifecycleProvider lifecycleProvider) { + Application application, + Lifecycle lifecycle, + PluginRegistry.Registrar registrar, + int activityHashCode) { final GoogleMapController controller = - new GoogleMapController(id, context, binaryMessenger, lifecycleProvider, options); + new GoogleMapController( + id, + context, + state, + binaryMessenger, + application, + lifecycle, + registrar, + activityHashCode, + options); controller.init(); controller.setMyLocationEnabled(myLocationEnabled); controller.setMyLocationButtonEnabled(myLocationButtonEnabled); @@ -45,9 +60,9 @@ GoogleMapController build( controller.setInitialMarkers(initialMarkers); controller.setInitialPolygons(initialPolygons); controller.setInitialPolylines(initialPolylines); + controller.setInitialHeatmaps(initialHeatmaps); controller.setInitialCircles(initialCircles); controller.setPadding(padding.top, padding.left, padding.bottom, padding.right); - controller.setInitialTileOverlays(initialTileOverlays); return controller; } @@ -115,11 +130,6 @@ public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { options.zoomGesturesEnabled(zoomGesturesEnabled); } - @Override - public void setLiteModeEnabled(boolean liteModeEnabled) { - options.liteMode(liteModeEnabled); - } - @Override public void setIndoorEnabled(boolean indoorEnabled) { this.indoorEnabled = indoorEnabled; @@ -171,7 +181,7 @@ public void setInitialCircles(Object initialCircles) { } @Override - public void setInitialTileOverlays(List> initialTileOverlays) { - this.initialTileOverlays = initialTileOverlays; + public void setInitialHeatmaps(Object initialHeatmaps) { + this.initialHeatmaps = initialHeatmaps; } } diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java index 05e016c32e27..b02c62c16dee 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapController.java @@ -1,11 +1,20 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package io.flutter.plugins.googlemaps; +import static io.flutter.plugins.googlemaps.GoogleMapsPlugin.CREATED; +import static io.flutter.plugins.googlemaps.GoogleMapsPlugin.DESTROYED; +import static io.flutter.plugins.googlemaps.GoogleMapsPlugin.PAUSED; +import static io.flutter.plugins.googlemaps.GoogleMapsPlugin.RESUMED; +import static io.flutter.plugins.googlemaps.GoogleMapsPlugin.STARTED; +import static io.flutter.plugins.googlemaps.GoogleMapsPlugin.STOPPED; + import android.Manifest; import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Application; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.Bitmap; @@ -36,6 +45,7 @@ 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.platform.PlatformView; import java.io.ByteArrayOutputStream; import java.util.ArrayList; @@ -43,10 +53,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; /** Controller of a single GoogleMaps MapView instance. */ final class GoogleMapController - implements DefaultLifecycleObserver, + implements Application.ActivityLifecycleCallbacks, + DefaultLifecycleObserver, ActivityPluginBinding.OnSaveInstanceStateListener, GoogleMapOptionsSink, MethodChannel.MethodCallHandler, @@ -56,9 +68,9 @@ final class GoogleMapController private static final String TAG = "GoogleMapController"; private final int id; + private final AtomicInteger activityState; private final MethodChannel methodChannel; - private final GoogleMapOptions options; - @Nullable private MapView mapView; + private final MapView mapView; private GoogleMap googleMap; private boolean trackCameraPosition = false; private boolean myLocationEnabled = false; @@ -70,38 +82,50 @@ final class GoogleMapController private boolean disposed = false; private final float density; private MethodChannel.Result mapReadyResult; + private final int + activityHashCode; // Do not use directly, use getActivityHashCode() instead to get correct hashCode for both v1 and v2 embedding. + private final Lifecycle lifecycle; private final Context context; - private final LifecycleProvider lifecycleProvider; + private final Application + mApplication; // Do not use direclty, use getApplication() instead to get correct application object for both v1 and v2 embedding. + private final PluginRegistry.Registrar registrar; // For v1 embedding only. private final MarkersController markersController; private final PolygonsController polygonsController; private final PolylinesController polylinesController; private final CirclesController circlesController; - private final TileOverlaysController tileOverlaysController; + private final HeatmapsController heatmapsController; private List initialMarkers; private List initialPolygons; private List initialPolylines; private List initialCircles; - private List> initialTileOverlays; + private List initialHeatmaps; GoogleMapController( int id, Context context, + AtomicInteger activityState, BinaryMessenger binaryMessenger, - LifecycleProvider lifecycleProvider, + Application application, + Lifecycle lifecycle, + PluginRegistry.Registrar registrar, + int registrarActivityHashCode, GoogleMapOptions options) { this.id = id; this.context = context; - this.options = options; + this.activityState = activityState; this.mapView = new MapView(context, options); this.density = context.getResources().getDisplayMetrics().density; methodChannel = new MethodChannel(binaryMessenger, "plugins.flutter.io/google_maps_" + id); methodChannel.setMethodCallHandler(this); - this.lifecycleProvider = lifecycleProvider; + mApplication = application; + this.lifecycle = lifecycle; + this.registrar = registrar; + this.activityHashCode = registrarActivityHashCode; this.markersController = new MarkersController(methodChannel); this.polygonsController = new PolygonsController(methodChannel, density); this.polylinesController = new PolylinesController(methodChannel, density); this.circlesController = new CirclesController(methodChannel, density); - this.tileOverlaysController = new TileOverlaysController(methodChannel); + this.heatmapsController = new HeatmapsController(methodChannel); } @Override @@ -110,7 +134,44 @@ public View getView() { } void init() { - lifecycleProvider.getLifecycle().addObserver(this); + switch (activityState.get()) { + case STOPPED: + mapView.onCreate(null); + mapView.onStart(); + mapView.onResume(); + mapView.onPause(); + mapView.onStop(); + break; + case PAUSED: + mapView.onCreate(null); + mapView.onStart(); + mapView.onResume(); + mapView.onPause(); + break; + case RESUMED: + mapView.onCreate(null); + mapView.onStart(); + mapView.onResume(); + break; + case STARTED: + mapView.onCreate(null); + mapView.onStart(); + break; + case CREATED: + mapView.onCreate(null); + break; + case DESTROYED: + // Nothing to do, the activity has been completely destroyed. + break; + default: + throw new IllegalArgumentException( + "Cannot interpret " + activityState.get() + " as an activity state"); + } + if (lifecycle != null) { + lifecycle.addObserver(this); + } else { + getApplication().registerActivityLifecycleCallbacks(this); + } mapView.getMapAsync(this); } @@ -143,12 +204,12 @@ public void onMapReady(GoogleMap googleMap) { polygonsController.setGoogleMap(googleMap); polylinesController.setGoogleMap(googleMap); circlesController.setGoogleMap(googleMap); - tileOverlaysController.setGoogleMap(googleMap); + heatmapsController.setGoogleMap(googleMap); updateInitialMarkers(); updateInitialPolygons(); updateInitialPolylines(); updateInitialCircles(); - updateInitialTileOverlays(); + updateInitialHeatmaps(); } @Override @@ -244,12 +305,12 @@ public void onSnapshotReady(Bitmap bitmap) { } case "markers#update": { - List markersToAdd = call.argument("markersToAdd"); - markersController.addMarkers(markersToAdd); - List markersToChange = call.argument("markersToChange"); - markersController.changeMarkers(markersToChange); - List markerIdsToRemove = call.argument("markerIdsToRemove"); - markersController.removeMarkers(markerIdsToRemove); + Object markersToAdd = call.argument("markersToAdd"); + markersController.addMarkers((List) markersToAdd); + Object markersToChange = call.argument("markersToChange"); + markersController.changeMarkers((List) markersToChange); + Object markerIdsToRemove = call.argument("markerIdsToRemove"); + markersController.removeMarkers((List) markerIdsToRemove); result.success(null); break; } @@ -273,34 +334,45 @@ public void onSnapshotReady(Bitmap bitmap) { } case "polygons#update": { - List polygonsToAdd = call.argument("polygonsToAdd"); - polygonsController.addPolygons(polygonsToAdd); - List polygonsToChange = call.argument("polygonsToChange"); - polygonsController.changePolygons(polygonsToChange); - List polygonIdsToRemove = call.argument("polygonIdsToRemove"); - polygonsController.removePolygons(polygonIdsToRemove); + Object polygonsToAdd = call.argument("polygonsToAdd"); + polygonsController.addPolygons((List) polygonsToAdd); + Object polygonsToChange = call.argument("polygonsToChange"); + polygonsController.changePolygons((List) polygonsToChange); + Object polygonIdsToRemove = call.argument("polygonIdsToRemove"); + polygonsController.removePolygons((List) polygonIdsToRemove); result.success(null); break; } case "polylines#update": { - List polylinesToAdd = call.argument("polylinesToAdd"); - polylinesController.addPolylines(polylinesToAdd); - List polylinesToChange = call.argument("polylinesToChange"); - polylinesController.changePolylines(polylinesToChange); - List polylineIdsToRemove = call.argument("polylineIdsToRemove"); - polylinesController.removePolylines(polylineIdsToRemove); + Object polylinesToAdd = call.argument("polylinesToAdd"); + polylinesController.addPolylines((List) polylinesToAdd); + Object polylinesToChange = call.argument("polylinesToChange"); + polylinesController.changePolylines((List) polylinesToChange); + Object polylineIdsToRemove = call.argument("polylineIdsToRemove"); + polylinesController.removePolylines((List) polylineIdsToRemove); result.success(null); break; } case "circles#update": { - List circlesToAdd = call.argument("circlesToAdd"); - circlesController.addCircles(circlesToAdd); - List circlesToChange = call.argument("circlesToChange"); - circlesController.changeCircles(circlesToChange); - List circleIdsToRemove = call.argument("circleIdsToRemove"); - circlesController.removeCircles(circleIdsToRemove); + Object circlesToAdd = call.argument("circlesToAdd"); + circlesController.addCircles((List) circlesToAdd); + Object circlesToChange = call.argument("circlesToChange"); + circlesController.changeCircles((List) circlesToChange); + Object circleIdsToRemove = call.argument("circleIdsToRemove"); + circlesController.removeCircles((List) circleIdsToRemove); + result.success(null); + break; + } + case "heatmaps#update": + { + Object heatmapsToAdd = call.argument("heatmapsToAdd"); + heatmapsController.addHeatmaps((List) heatmapsToAdd); + Object heatmapsToChange = call.argument("heatmapsToChange"); + heatmapsController.changeHeatmaps((List) heatmapsToChange); + Object heatmapIdsToRemove = call.argument("heatmapIdsToRemove"); + heatmapsController.removeHeatmaps((List) heatmapIdsToRemove); result.success(null); break; } @@ -327,11 +399,6 @@ public void onSnapshotReady(Bitmap bitmap) { result.success(googleMap.getUiSettings().isZoomGesturesEnabled()); break; } - case "map#isLiteModeEnabled": - { - result.success(options.getLiteMode()); - break; - } case "map#isZoomControlsEnabled": { result.success(googleMap.getUiSettings().isZoomControlsEnabled()); @@ -390,30 +457,6 @@ public void onSnapshotReady(Bitmap bitmap) { result.success(mapStyleResult); break; } - case "tileOverlays#update": - { - List> tileOverlaysToAdd = call.argument("tileOverlaysToAdd"); - tileOverlaysController.addTileOverlays(tileOverlaysToAdd); - List> tileOverlaysToChange = call.argument("tileOverlaysToChange"); - tileOverlaysController.changeTileOverlays(tileOverlaysToChange); - List tileOverlaysToRemove = call.argument("tileOverlayIdsToRemove"); - tileOverlaysController.removeTileOverlays(tileOverlaysToRemove); - result.success(null); - break; - } - case "tileOverlays#clearTileCache": - { - String tileOverlayId = call.argument("tileOverlayId"); - tileOverlaysController.clearTileCache(tileOverlayId); - result.success(null); - break; - } - case "map#getTileOverlayInfo": - { - String tileOverlayId = call.argument("tileOverlayId"); - result.success(tileOverlaysController.getTileOverlayInfo(tileOverlayId)); - break; - } default: result.notImplemented(); } @@ -500,11 +543,7 @@ public void dispose() { disposed = true; methodChannel.setMethodCallHandler(null); setGoogleMapListener(null); - destroyMapViewIfNecessary(); - Lifecycle lifecycle = lifecycleProvider.getLifecycle(); - if (lifecycle != null) { - lifecycle.removeObserver(this); - } + getApplication().unregisterActivityLifecycleCallbacks(this); } private void setGoogleMapListener(@Nullable GoogleMapListener listener) { @@ -525,16 +564,73 @@ private void setGoogleMapListener(@Nullable GoogleMapListener listener) { // does. This will override it when available even with the annotation commented out. public void onInputConnectionLocked() { // TODO(mklim): Remove this empty override once https://github.com/flutter/flutter/issues/40126 is fixed in stable. - } + }; // @Override // The minimum supported version of Flutter doesn't have this method on the PlatformView interface, but the maximum // does. This will override it when available even with the annotation commented out. public void onInputConnectionUnlocked() { // TODO(mklim): Remove this empty override once https://github.com/flutter/flutter/issues/40126 is fixed in stable. + }; + + // Application.ActivityLifecycleCallbacks methods + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + if (disposed || activity.hashCode() != getActivityHashCode()) { + return; + } + mapView.onCreate(savedInstanceState); } - // DefaultLifecycleObserver + @Override + public void onActivityStarted(Activity activity) { + if (disposed || activity.hashCode() != getActivityHashCode()) { + return; + } + mapView.onStart(); + } + + @Override + public void onActivityResumed(Activity activity) { + if (disposed || activity.hashCode() != getActivityHashCode()) { + return; + } + mapView.onResume(); + } + + @Override + public void onActivityPaused(Activity activity) { + if (disposed || activity.hashCode() != getActivityHashCode()) { + return; + } + mapView.onPause(); + } + + @Override + public void onActivityStopped(Activity activity) { + if (disposed || activity.hashCode() != getActivityHashCode()) { + return; + } + mapView.onStop(); + } + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) { + if (disposed || activity.hashCode() != getActivityHashCode()) { + return; + } + mapView.onSaveInstanceState(outState); + } + + @Override + public void onActivityDestroyed(Activity activity) { + if (disposed || activity.hashCode() != getActivityHashCode()) { + return; + } + mapView.onDestroy(); + } + + // DefaultLifecycleObserver and OnSaveInstanceStateListener @Override public void onCreate(@NonNull LifecycleOwner owner) { @@ -578,11 +674,10 @@ public void onStop(@NonNull LifecycleOwner owner) { @Override public void onDestroy(@NonNull LifecycleOwner owner) { - owner.getLifecycle().removeObserver(this); if (disposed) { return; } - destroyMapViewIfNecessary(); + mapView.onDestroy(); } @Override @@ -670,12 +765,6 @@ public void setZoomGesturesEnabled(boolean zoomGesturesEnabled) { googleMap.getUiSettings().setZoomGesturesEnabled(zoomGesturesEnabled); } - /** This call will have no effect on already created map */ - @Override - public void setLiteModeEnabled(boolean liteModeEnabled) { - options.liteMode(liteModeEnabled); - } - @Override public void setMyLocationEnabled(boolean myLocationEnabled) { if (this.myLocationEnabled == myLocationEnabled) { @@ -711,8 +800,7 @@ public void setZoomControlsEnabled(boolean zoomControlsEnabled) { @Override public void setInitialMarkers(Object initialMarkers) { - ArrayList markers = (ArrayList) initialMarkers; - this.initialMarkers = markers != null ? new ArrayList<>(markers) : null; + this.initialMarkers = (List) initialMarkers; if (googleMap != null) { updateInitialMarkers(); } @@ -724,8 +812,7 @@ private void updateInitialMarkers() { @Override public void setInitialPolygons(Object initialPolygons) { - ArrayList polygons = (ArrayList) initialPolygons; - this.initialPolygons = polygons != null ? new ArrayList<>(polygons) : null; + this.initialPolygons = (List) initialPolygons; if (googleMap != null) { updateInitialPolygons(); } @@ -737,8 +824,7 @@ private void updateInitialPolygons() { @Override public void setInitialPolylines(Object initialPolylines) { - ArrayList polylines = (ArrayList) initialPolylines; - this.initialPolylines = polylines != null ? new ArrayList<>(polylines) : null; + this.initialPolylines = (List) initialPolylines; if (googleMap != null) { updateInitialPolylines(); } @@ -750,8 +836,7 @@ private void updateInitialPolylines() { @Override public void setInitialCircles(Object initialCircles) { - ArrayList circles = (ArrayList) initialCircles; - this.initialCircles = circles != null ? new ArrayList<>(circles) : null; + this.initialCircles = (List) initialCircles; if (googleMap != null) { updateInitialCircles(); } @@ -762,15 +847,15 @@ private void updateInitialCircles() { } @Override - public void setInitialTileOverlays(List> initialTileOverlays) { - this.initialTileOverlays = initialTileOverlays; + public void setInitialHeatmaps(Object initialHeatmaps) { + this.initialHeatmaps = (List) initialHeatmaps; if (googleMap != null) { - updateInitialTileOverlays(); + updateInitialHeatmaps(); } } - private void updateInitialTileOverlays() { - tileOverlaysController.addTileOverlays(initialTileOverlays); + private void updateInitialHeatmaps() { + heatmapsController.addHeatmaps(initialHeatmaps); } @SuppressLint("MissingPermission") @@ -805,12 +890,20 @@ private int checkSelfPermission(String permission) { permission, android.os.Process.myPid(), android.os.Process.myUid()); } - private void destroyMapViewIfNecessary() { - if (mapView == null) { - return; + private int getActivityHashCode() { + if (registrar != null && registrar.activity() != null) { + return registrar.activity().hashCode(); + } else { + return activityHashCode; + } + } + + private Application getApplication() { + if (registrar != null && registrar.activity() != null) { + return registrar.activity().getApplication(); + } else { + return mApplication; } - mapView.onDestroy(); - mapView = null; } public void setIndoorEnabled(boolean indoorEnabled) { @@ -829,3 +922,16 @@ public void setBuildingsEnabled(boolean buildingsEnabled) { this.buildingsEnabled = buildingsEnabled; } } + +interface GoogleMapListener + extends GoogleMap.OnCameraIdleListener, + GoogleMap.OnCameraMoveListener, + GoogleMap.OnCameraMoveStartedListener, + GoogleMap.OnInfoWindowClickListener, + GoogleMap.OnMarkerClickListener, + GoogleMap.OnPolygonClickListener, + GoogleMap.OnPolylineClickListener, + GoogleMap.OnCircleClickListener, + GoogleMap.OnMapClickListener, + GoogleMap.OnMapLongClickListener, + GoogleMap.OnMarkerDragListener {} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java index ca9ac184a76e..0278c34f1b9f 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapFactory.java @@ -1,27 +1,44 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package io.flutter.plugins.googlemaps; +import android.app.Application; import android.content.Context; +import androidx.lifecycle.Lifecycle; import com.google.android.gms.maps.model.CameraPosition; import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.PluginRegistry; import io.flutter.plugin.common.StandardMessageCodec; import io.flutter.plugin.platform.PlatformView; import io.flutter.plugin.platform.PlatformViewFactory; -import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; public class GoogleMapFactory extends PlatformViewFactory { + private final AtomicInteger mActivityState; private final BinaryMessenger binaryMessenger; - private final LifecycleProvider lifecycleProvider; + private final Application application; + private final int activityHashCode; + private final Lifecycle lifecycle; + private final PluginRegistry.Registrar registrar; // V1 embedding only. - GoogleMapFactory(BinaryMessenger binaryMessenger, LifecycleProvider lifecycleProvider) { + GoogleMapFactory( + AtomicInteger state, + BinaryMessenger binaryMessenger, + Application application, + Lifecycle lifecycle, + PluginRegistry.Registrar registrar, + int activityHashCode) { super(StandardMessageCodec.INSTANCE); + mActivityState = state; this.binaryMessenger = binaryMessenger; - this.lifecycleProvider = lifecycleProvider; + this.application = application; + this.activityHashCode = activityHashCode; + this.lifecycle = lifecycle; + this.registrar = registrar; } @SuppressWarnings("unchecked") @@ -47,9 +64,17 @@ public PlatformView create(Context context, int id, Object args) { if (params.containsKey("circlesToAdd")) { builder.setInitialCircles(params.get("circlesToAdd")); } - if (params.containsKey("tileOverlaysToAdd")) { - builder.setInitialTileOverlays((List>) params.get("tileOverlaysToAdd")); + if (params.containsKey("heatmapsToAdd")) { + builder.setInitialHeatmaps(params.get("heatmapsToAdd")); } - return builder.build(id, context, binaryMessenger, lifecycleProvider); + return builder.build( + id, + context, + mActivityState, + binaryMessenger, + application, + lifecycle, + registrar, + activityHashCode); } } diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapListener.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapListener.java deleted file mode 100644 index 0a5c3ec67e27..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapListener.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.googlemaps; - -import com.google.android.gms.maps.GoogleMap; - -interface GoogleMapListener - extends GoogleMap.OnCameraIdleListener, - GoogleMap.OnCameraMoveListener, - GoogleMap.OnCameraMoveStartedListener, - GoogleMap.OnInfoWindowClickListener, - GoogleMap.OnMarkerClickListener, - GoogleMap.OnPolygonClickListener, - GoogleMap.OnPolylineClickListener, - GoogleMap.OnCircleClickListener, - GoogleMap.OnMapClickListener, - GoogleMap.OnMapLongClickListener, - GoogleMap.OnMarkerDragListener {} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java index 17f0d970a4ef..ac6571245ed9 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapOptionsSink.java @@ -1,12 +1,10 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package io.flutter.plugins.googlemaps; import com.google.android.gms.maps.model.LatLngBounds; -import java.util.List; -import java.util.Map; /** Receiver of GoogleMap configuration options. */ interface GoogleMapOptionsSink { @@ -32,8 +30,6 @@ interface GoogleMapOptionsSink { void setZoomGesturesEnabled(boolean zoomGesturesEnabled); - void setLiteModeEnabled(boolean liteModeEnabled); - void setMyLocationEnabled(boolean myLocationEnabled); void setZoomControlsEnabled(boolean zoomControlsEnabled); @@ -54,5 +50,5 @@ interface GoogleMapOptionsSink { void setInitialCircles(Object initialCircles); - void setInitialTileOverlays(List> initialTileOverlays); + void setInitialHeatmaps(Object initialHeatmaps); } diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java index 763cd9e3e72e..9f9f378737df 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/GoogleMapsPlugin.java @@ -1,22 +1,22 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package io.flutter.plugins.googlemaps; import android.app.Activity; -import android.app.Application.ActivityLifecycleCallbacks; +import android.app.Application; import android.os.Bundle; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; +import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.Lifecycle.Event; import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.LifecycleRegistry; 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.PluginRegistry.Registrar; +import java.util.concurrent.atomic.AtomicInteger; /** * Plugin for controlling a set of GoogleMap views to be shown as overlays on top of the Flutter @@ -24,41 +24,37 @@ * the map. A Texture drawn using GoogleMap bitmap snapshots can then be shown instead of the * overlay. */ -public class GoogleMapsPlugin implements FlutterPlugin, ActivityAware { - - @Nullable private Lifecycle lifecycle; +public class GoogleMapsPlugin + implements Application.ActivityLifecycleCallbacks, + FlutterPlugin, + ActivityAware, + DefaultLifecycleObserver { + static final int CREATED = 1; + static final int STARTED = 2; + static final int RESUMED = 3; + static final int PAUSED = 4; + static final int STOPPED = 5; + static final int DESTROYED = 6; + private final AtomicInteger state = new AtomicInteger(0); + private int registrarActivityHashCode; + private FlutterPluginBinding pluginBinding; + private Lifecycle lifecycle; private static final String VIEW_TYPE = "plugins.flutter.io/google_maps"; - @SuppressWarnings("deprecation") - public static void registerWith( - final io.flutter.plugin.common.PluginRegistry.Registrar registrar) { - final Activity activity = registrar.activity(); - if (activity == null) { + public static void registerWith(Registrar registrar) { + if (registrar.activity() == null) { // When a background flutter view tries to register the plugin, the registrar has no activity. // We stop the registration process as this plugin is foreground only. return; } - if (activity instanceof LifecycleOwner) { - registrar - .platformViewRegistry() - .registerViewFactory( - VIEW_TYPE, - new GoogleMapFactory( - registrar.messenger(), - new LifecycleProvider() { - @Override - public Lifecycle getLifecycle() { - return ((LifecycleOwner) activity).getLifecycle(); - } - })); - } else { - registrar - .platformViewRegistry() - .registerViewFactory( - VIEW_TYPE, - new GoogleMapFactory(registrar.messenger(), new ProxyLifecycleProvider(activity))); - } + final GoogleMapsPlugin plugin = new GoogleMapsPlugin(registrar.activity()); + registrar.activity().getApplication().registerActivityLifecycleCallbacks(plugin); + registrar + .platformViewRegistry() + .registerViewFactory( + VIEW_TYPE, + new GoogleMapFactory(plugin.state, registrar.messenger(), null, null, registrar, -1)); } public GoogleMapsPlugin() {} @@ -67,119 +63,136 @@ public GoogleMapsPlugin() {} @Override public void onAttachedToEngine(FlutterPluginBinding binding) { - binding - .getPlatformViewRegistry() - .registerViewFactory( - VIEW_TYPE, - new GoogleMapFactory( - binding.getBinaryMessenger(), - new LifecycleProvider() { - @Nullable - @Override - public Lifecycle getLifecycle() { - return lifecycle; - } - })); + pluginBinding = binding; } @Override - public void onDetachedFromEngine(FlutterPluginBinding binding) {} + public void onDetachedFromEngine(FlutterPluginBinding binding) { + pluginBinding = null; + } // ActivityAware @Override public void onAttachedToActivity(ActivityPluginBinding binding) { lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding); + lifecycle.addObserver(this); + pluginBinding + .getPlatformViewRegistry() + .registerViewFactory( + VIEW_TYPE, + new GoogleMapFactory( + state, + pluginBinding.getBinaryMessenger(), + binding.getActivity().getApplication(), + lifecycle, + null, + binding.getActivity().hashCode())); } @Override public void onDetachedFromActivity() { - lifecycle = null; + lifecycle.removeObserver(this); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + this.onDetachedFromActivity(); } @Override public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) { - onAttachedToActivity(binding); + lifecycle = FlutterLifecycleAdapter.getActivityLifecycle(binding); + lifecycle.addObserver(this); } + // DefaultLifecycleObserver methods + @Override - public void onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity(); + public void onCreate(@NonNull LifecycleOwner owner) { + state.set(CREATED); } - /** - * This class provides a {@link LifecycleOwner} for the activity driven by {@link - * ActivityLifecycleCallbacks}. - * - *

This is used in the case where a direct Lifecycle/Owner is not available. - */ - private static final class ProxyLifecycleProvider - implements ActivityLifecycleCallbacks, LifecycleOwner, LifecycleProvider { + @Override + public void onStart(@NonNull LifecycleOwner owner) { + state.set(STARTED); + } - private final LifecycleRegistry lifecycle = new LifecycleRegistry(this); - private final int registrarActivityHashCode; + @Override + public void onResume(@NonNull LifecycleOwner owner) { + state.set(RESUMED); + } - private ProxyLifecycleProvider(Activity activity) { - this.registrarActivityHashCode = activity.hashCode(); - activity.getApplication().registerActivityLifecycleCallbacks(this); - } + @Override + public void onPause(@NonNull LifecycleOwner owner) { + state.set(PAUSED); + } - @Override - public void onActivityCreated(Activity activity, Bundle savedInstanceState) { - if (activity.hashCode() != registrarActivityHashCode) { - return; - } - lifecycle.handleLifecycleEvent(Event.ON_CREATE); + @Override + public void onStop(@NonNull LifecycleOwner owner) { + state.set(STOPPED); + } + + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + state.set(DESTROYED); + } + + // Application.ActivityLifecycleCallbacks methods + + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + if (activity.hashCode() != registrarActivityHashCode) { + return; } + state.set(CREATED); + } - @Override - public void onActivityStarted(Activity activity) { - if (activity.hashCode() != registrarActivityHashCode) { - return; - } - lifecycle.handleLifecycleEvent(Event.ON_START); + @Override + public void onActivityStarted(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; } + state.set(STARTED); + } - @Override - public void onActivityResumed(Activity activity) { - if (activity.hashCode() != registrarActivityHashCode) { - return; - } - lifecycle.handleLifecycleEvent(Event.ON_RESUME); + @Override + public void onActivityResumed(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; } + state.set(RESUMED); + } - @Override - public void onActivityPaused(Activity activity) { - if (activity.hashCode() != registrarActivityHashCode) { - return; - } - lifecycle.handleLifecycleEvent(Event.ON_PAUSE); + @Override + public void onActivityPaused(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; } + state.set(PAUSED); + } - @Override - public void onActivityStopped(Activity activity) { - if (activity.hashCode() != registrarActivityHashCode) { - return; - } - lifecycle.handleLifecycleEvent(Event.ON_STOP); + @Override + public void onActivityStopped(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; } + state.set(STOPPED); + } - @Override - public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} - @Override - public void onActivityDestroyed(Activity activity) { - if (activity.hashCode() != registrarActivityHashCode) { - return; - } - activity.getApplication().unregisterActivityLifecycleCallbacks(this); - lifecycle.handleLifecycleEvent(Event.ON_DESTROY); + @Override + public void onActivityDestroyed(Activity activity) { + if (activity.hashCode() != registrarActivityHashCode) { + return; } + activity.getApplication().unregisterActivityLifecycleCallbacks(this); + state.set(DESTROYED); + } - @NonNull - @Override - public Lifecycle getLifecycle() { - return lifecycle; - } + private GoogleMapsPlugin(Activity activity) { + this.registrarActivityHashCode = activity.hashCode(); } } diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapBuilder.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapBuilder.java new file mode 100644 index 000000000000..45efafb49b2d --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapBuilder.java @@ -0,0 +1,65 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.HeatmapTileProvider; +import com.google.maps.android.heatmaps.WeightedLatLng; +import java.util.List; + +class HeatmapBuilder implements HeatmapOptionsSink { + private final HeatmapOptions heatmapOptions; + + HeatmapBuilder() { + heatmapOptions = new HeatmapOptions(); + } + + HeatmapOptions build() { + HeatmapTileProvider heatmapTileProvider = + heatmapOptions.getHeatmapTileProviderBuilder().build(); + heatmapOptions.setHeatmapTileProvider(heatmapTileProvider); + return heatmapOptions; + } + + @Override + public void setPoints(List points) { + heatmapOptions.setPoints(points); + } + + @Override + public void setGradient(Gradient gradient) { + heatmapOptions.setGradient(gradient); + } + + @Override + public void setOpacity(double opacity) { + heatmapOptions.setOpacity(opacity); + } + + @Override + public void setRadius(int radius) { + heatmapOptions.setRadius(radius); + } + + @Override + public void setFadeIn(boolean fadeIn) { + heatmapOptions.setFadeIn(fadeIn); + } + + @Override + public void setTransparency(float transparency) { + heatmapOptions.setTransparency(transparency); + } + + @Override + public void setVisible(boolean visible) { + heatmapOptions.setVisible(visible); + } + + @Override + public void setZIndex(float zIndex) { + heatmapOptions.setZIndex(zIndex); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapController.java new file mode 100644 index 000000000000..257e768d3d67 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapController.java @@ -0,0 +1,82 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import com.google.android.gms.maps.model.TileOverlay; +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.WeightedLatLng; +import java.util.List; + +/** Controller of a single Heatmap on the map. */ +class HeatmapController implements HeatmapOptionsSink { + + private final HeatmapOptions mHeatmapOptions; + + private final TileOverlay mTileOverlay; + + private final String mGoogleMapsHeatmapId; + + HeatmapController(HeatmapOptions heatmapOptions, TileOverlay tileOverlay) { + mHeatmapOptions = heatmapOptions; + mTileOverlay = tileOverlay; + mGoogleMapsHeatmapId = tileOverlay.getId(); + } + + void remove() { + mTileOverlay.remove(); + } + + private void clearTileOverlayCache() { + mTileOverlay.clearTileCache(); + } + + @Override + public void setPoints(List points) { + mHeatmapOptions.setPoints(points); + clearTileOverlayCache(); + } + + @Override + public void setGradient(Gradient gradient) { + mHeatmapOptions.setGradient(gradient); + clearTileOverlayCache(); + } + + @Override + public void setOpacity(double opacity) { + mHeatmapOptions.setOpacity(opacity); + clearTileOverlayCache(); + } + + @Override + public void setRadius(int radius) { + mHeatmapOptions.setRadius(radius); + clearTileOverlayCache(); + } + + @Override + public void setFadeIn(boolean fadeIn) { + mHeatmapOptions.setFadeIn(fadeIn); + } + + @Override + public void setTransparency(float transparency) { + mHeatmapOptions.setTransparency(transparency); + } + + @Override + public void setVisible(boolean visible) { + mHeatmapOptions.setVisible(visible); + } + + @Override + public void setZIndex(float zIndex) { + mHeatmapOptions.setZIndex(zIndex); + } + + String getGoogleMapsHeatmapId() { + return mGoogleMapsHeatmapId; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptions.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptions.java new file mode 100644 index 000000000000..642c0ce12d4d --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptions.java @@ -0,0 +1,114 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import com.google.android.gms.maps.model.TileOverlay; +import com.google.android.gms.maps.model.TileOverlayOptions; +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.HeatmapTileProvider; +import com.google.maps.android.heatmaps.WeightedLatLng; +import java.util.List; + +/** Receiver of Heatmap configuration options. */ +class HeatmapOptions implements HeatmapOptionsSink { + + private final HeatmapTileProvider.Builder mHeatmapTileProviderBuilder; + + private HeatmapTileProvider mHeatmapTileProvider; + + private final TileOverlayOptions mTileOverlayOptions; + + private TileOverlay mTileOverlay; + + public HeatmapOptions() { + mHeatmapTileProviderBuilder = new HeatmapTileProvider.Builder(); + mTileOverlayOptions = new TileOverlayOptions(); + } + + public void setPoints(List points) { + if (null == mHeatmapTileProvider) { + mHeatmapTileProviderBuilder.weightedData(points); + } else { + mHeatmapTileProvider.setWeightedData(points); + } + } + + public void setGradient(Gradient gradient) { + if (null == mHeatmapTileProvider) { + mHeatmapTileProviderBuilder.gradient(gradient); + } else { + mHeatmapTileProvider.setGradient(gradient); + } + } + + public void setOpacity(double opacity) { + if (null == mHeatmapTileProvider) { + mHeatmapTileProviderBuilder.opacity(opacity); + } else { + mHeatmapTileProvider.setOpacity(opacity); + } + } + + public void setRadius(int radius) { + if (null == mHeatmapTileProvider) { + mHeatmapTileProviderBuilder.radius(radius); + } else { + mHeatmapTileProvider.setRadius(radius); + } + } + + public void setFadeIn(boolean fadeIn) { + if (null == mTileOverlay) { + mTileOverlayOptions.fadeIn(fadeIn); + } else { + mTileOverlay.setFadeIn(fadeIn); + } + } + + public void setTransparency(float transparency) { + if (null == mTileOverlay) { + mTileOverlayOptions.transparency(transparency); + } else { + mTileOverlay.setTransparency(transparency); + } + } + + public void setVisible(boolean visible) { + if (null == mTileOverlay) { + mTileOverlayOptions.visible(visible); + } else { + mTileOverlay.setVisible(visible); + } + } + + public void setZIndex(float zIndex) { + if (null == mTileOverlay) { + mTileOverlayOptions.zIndex(zIndex); + } else { + mTileOverlay.setZIndex(zIndex); + } + } + + public HeatmapTileProvider.Builder getHeatmapTileProviderBuilder() { + return mHeatmapTileProviderBuilder; + } + + public HeatmapTileProvider getHeatmapTileProvider() { + return mHeatmapTileProvider; + } + + public void setHeatmapTileProvider(HeatmapTileProvider heatmapTileProvider) { + mHeatmapTileProvider = heatmapTileProvider; + mTileOverlayOptions.tileProvider(mHeatmapTileProvider); + } + + public TileOverlayOptions getTileOverlayOptions() { + return mTileOverlayOptions; + } + + public void setTileOverlay(TileOverlay tileOverlay) { + mTileOverlay = tileOverlay; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptionsSink.java new file mode 100644 index 000000000000..fdec5b4faa55 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapOptionsSink.java @@ -0,0 +1,29 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import com.google.maps.android.heatmaps.Gradient; +import com.google.maps.android.heatmaps.WeightedLatLng; +import java.util.List; + +/** Receiver of Heatmap configuration options. */ +interface HeatmapOptionsSink { + + void setPoints(List points); + + void setGradient(Gradient gradient); + + void setOpacity(double opacity); + + void setRadius(int radius); + + void setFadeIn(boolean fadeIn); + + void setTransparency(float transparency); + + void setVisible(boolean visible); + + void setZIndex(float zIndex); +} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapsController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapsController.java new file mode 100644 index 000000000000..00e82a359c93 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/HeatmapsController.java @@ -0,0 +1,100 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.TileOverlay; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class HeatmapsController { + + private final Map mHeatmapIdToController; + private final Map mGoogleMapsHeatmapIdToDartHeatmapId; + private final MethodChannel mMethodChannel; + private GoogleMap mGoogleMap; + + HeatmapsController(MethodChannel methodChannel) { + mHeatmapIdToController = new HashMap<>(); + mGoogleMapsHeatmapIdToDartHeatmapId = new HashMap<>(); + mMethodChannel = methodChannel; + } + + void setGoogleMap(GoogleMap googleMap) { + mGoogleMap = googleMap; + } + + void addHeatmaps(List heatmapsToAdd) { + if (heatmapsToAdd != null) { + for (Object heatmapToAdd : heatmapsToAdd) { + addHeatmap(heatmapToAdd); + } + } + } + + void changeHeatmaps(List heatmapsToChange) { + if (heatmapsToChange != null) { + for (Object heatmapToChange : heatmapsToChange) { + changeHeatmap(heatmapToChange); + } + } + } + + void removeHeatmaps(List heatmapIdsToRemove) { + if (heatmapIdsToRemove == null) { + return; + } + for (Object rawHeatmapId : heatmapIdsToRemove) { + if (rawHeatmapId == null) { + continue; + } + String heatmapId = (String) rawHeatmapId; + final HeatmapController heatmapController = mHeatmapIdToController.remove(heatmapId); + if (heatmapController != null) { + heatmapController.remove(); + mGoogleMapsHeatmapIdToDartHeatmapId.remove(heatmapController.getGoogleMapsHeatmapId()); + } + } + } + + private void addHeatmap(Object heatmap) { + if (heatmap == null) { + return; + } + HeatmapBuilder heatmapBuilder = new HeatmapBuilder(); + String heatmapId = Convert.interpretHeatmapOptions(heatmap, heatmapBuilder); + HeatmapOptions options = heatmapBuilder.build(); + addHeatmap(heatmapId, options); + } + + private void addHeatmap(String heatmapId, HeatmapOptions heatmapOptions) { + + TileOverlay overlay = mGoogleMap.addTileOverlay(heatmapOptions.getTileOverlayOptions()); + heatmapOptions.setTileOverlay(overlay); + + HeatmapController controller = new HeatmapController(heatmapOptions, overlay); + mHeatmapIdToController.put(heatmapId, controller); + mGoogleMapsHeatmapIdToDartHeatmapId.put(overlay.getId(), heatmapId); + } + + private void changeHeatmap(Object heatmap) { + if (heatmap == null) { + return; + } + String heatmapId = getHeatmapId(heatmap); + HeatmapController heatmapController = mHeatmapIdToController.get(heatmapId); + if (heatmapController != null) { + Convert.interpretHeatmapOptions(heatmap, heatmapController); + } + } + + @SuppressWarnings("unchecked") + private static String getHeatmapId(Object heatmap) { + Map heatmapMap = (Map) heatmap; + return (String) heatmapMap.get("heatmapId"); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/LifecycleProvider.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/LifecycleProvider.java deleted file mode 100644 index a3b6c0a3adf0..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/LifecycleProvider.java +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package io.flutter.plugins.googlemaps; - -import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; - -interface LifecycleProvider { - - @Nullable - Lifecycle getLifecycle(); -} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java index ecc5f01bc87c..29e4de00c5b0 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerBuilder.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java index 5c568a1c9a1e..412daee5cf68 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerController.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerOptionsSink.java index 88c970c1f14b..3f853b9f1459 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerOptionsSink.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkerOptionsSink.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java index 189cba03c1cd..70feb978af3f 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/MarkersController.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonBuilder.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonBuilder.java index 072fa746958f..600762afe4ee 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonBuilder.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonBuilder.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -41,13 +41,6 @@ public void setPoints(List points) { polygonOptions.addAll(points); } - @Override - public void setHoles(List> holes) { - for (List hole : holes) { - polygonOptions.addHole(hole); - } - } - @Override public void setConsumeTapEvents(boolean consumeTapEvents) { this.consumeTapEvents = consumeTapEvents; diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonController.java index e66f05e18f93..adb01b8a490a 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonController.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -52,10 +52,6 @@ public void setPoints(List points) { polygon.setPoints(points); } - public void setHoles(List> holes) { - polygon.setHoles(holes); - } - @Override public void setVisible(boolean visible) { polygon.setVisible(visible); diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonOptionsSink.java index e9b0ec1413a2..df4dae0fda4e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonOptionsSink.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonOptionsSink.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -20,8 +20,6 @@ interface PolygonOptionsSink { void setPoints(List points); - void setHoles(List> holes); - void setVisible(boolean visible); void setStrokeWidth(float width); diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonsController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonsController.java index 6f855db07996..07f2ad0f7c38 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonsController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolygonsController.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineBuilder.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineBuilder.java index 9120a1618237..9fd242a4706f 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineBuilder.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineBuilder.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineController.java index 8bd84f5906f2..ec0fed83be49 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineController.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineOptionsSink.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineOptionsSink.java index 5b3f193617cb..adaf867b92d1 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineOptionsSink.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylineOptionsSink.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylinesController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylinesController.java index 399634933dc9..a6ad61adc170 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylinesController.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/PolylinesController.java @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlayBuilder.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlayBuilder.java deleted file mode 100644 index ecbc2f8f9ee1..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlayBuilder.java +++ /dev/null @@ -1,46 +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 com.google.android.gms.maps.model.TileOverlayOptions; -import com.google.android.gms.maps.model.TileProvider; - -class TileOverlayBuilder implements TileOverlaySink { - - private final TileOverlayOptions tileOverlayOptions; - - TileOverlayBuilder() { - this.tileOverlayOptions = new TileOverlayOptions(); - } - - TileOverlayOptions build() { - return tileOverlayOptions; - } - - @Override - public void setFadeIn(boolean fadeIn) { - tileOverlayOptions.fadeIn(fadeIn); - } - - @Override - public void setTransparency(float transparency) { - tileOverlayOptions.transparency(transparency); - } - - @Override - public void setZIndex(float zIndex) { - tileOverlayOptions.zIndex(zIndex); - } - - @Override - public void setVisible(boolean visible) { - tileOverlayOptions.visible(visible); - } - - @Override - public void setTileProvider(TileProvider tileProvider) { - tileOverlayOptions.tileProvider(tileProvider); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlayController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlayController.java deleted file mode 100644 index 7405b5fcc496..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlayController.java +++ /dev/null @@ -1,62 +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 com.google.android.gms.maps.model.TileOverlay; -import com.google.android.gms.maps.model.TileProvider; -import java.util.HashMap; -import java.util.Map; - -class TileOverlayController implements TileOverlaySink { - - private final TileOverlay tileOverlay; - - TileOverlayController(TileOverlay tileOverlay) { - this.tileOverlay = tileOverlay; - } - - void remove() { - tileOverlay.remove(); - } - - void clearTileCache() { - tileOverlay.clearTileCache(); - } - - Map getTileOverlayInfo() { - Map tileOverlayInfo = new HashMap<>(); - tileOverlayInfo.put("fadeIn", tileOverlay.getFadeIn()); - tileOverlayInfo.put("transparency", tileOverlay.getTransparency()); - tileOverlayInfo.put("id", tileOverlay.getId()); - tileOverlayInfo.put("zIndex", tileOverlay.getZIndex()); - tileOverlayInfo.put("visible", tileOverlay.isVisible()); - return tileOverlayInfo; - } - - @Override - public void setFadeIn(boolean fadeIn) { - tileOverlay.setFadeIn(fadeIn); - } - - @Override - public void setTransparency(float transparency) { - tileOverlay.setTransparency(transparency); - } - - @Override - public void setZIndex(float zIndex) { - tileOverlay.setZIndex(zIndex); - } - - @Override - public void setVisible(boolean visible) { - tileOverlay.setVisible(visible); - } - - @Override - public void setTileProvider(TileProvider tileProvider) { - // You can not change tile provider after creation - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlaySink.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlaySink.java deleted file mode 100644 index d167af7d4a6d..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlaySink.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.googlemaps; - -import com.google.android.gms.maps.model.TileProvider; - -/** Receiver of TileOverlayOptions configuration. */ -interface TileOverlaySink { - void setFadeIn(boolean fadeIn); - - void setTransparency(float transparency); - - void setZIndex(float zIndex); - - void setVisible(boolean visible); - - void setTileProvider(TileProvider tileProvider); -} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlaysController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlaysController.java deleted file mode 100644 index 82a3edcb32c0..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileOverlaysController.java +++ /dev/null @@ -1,120 +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 com.google.android.gms.maps.GoogleMap; -import com.google.android.gms.maps.model.TileOverlay; -import com.google.android.gms.maps.model.TileOverlayOptions; -import io.flutter.plugin.common.MethodChannel; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -class TileOverlaysController { - - private final Map tileOverlayIdToController; - private final MethodChannel methodChannel; - private GoogleMap googleMap; - - TileOverlaysController(MethodChannel methodChannel) { - this.tileOverlayIdToController = new HashMap<>(); - this.methodChannel = methodChannel; - } - - void setGoogleMap(GoogleMap googleMap) { - this.googleMap = googleMap; - } - - void addTileOverlays(List> tileOverlaysToAdd) { - if (tileOverlaysToAdd == null) { - return; - } - for (Map tileOverlayToAdd : tileOverlaysToAdd) { - addTileOverlay(tileOverlayToAdd); - } - } - - void changeTileOverlays(List> tileOverlaysToChange) { - if (tileOverlaysToChange == null) { - return; - } - for (Map tileOverlayToChange : tileOverlaysToChange) { - changeTileOverlay(tileOverlayToChange); - } - } - - void removeTileOverlays(List tileOverlayIdsToRemove) { - if (tileOverlayIdsToRemove == null) { - return; - } - for (String tileOverlayId : tileOverlayIdsToRemove) { - if (tileOverlayId == null) { - continue; - } - removeTileOverlay(tileOverlayId); - } - } - - void clearTileCache(String tileOverlayId) { - if (tileOverlayId == null) { - return; - } - TileOverlayController tileOverlayController = tileOverlayIdToController.get(tileOverlayId); - if (tileOverlayController != null) { - tileOverlayController.clearTileCache(); - } - } - - Map getTileOverlayInfo(String tileOverlayId) { - if (tileOverlayId == null) { - return null; - } - TileOverlayController tileOverlayController = tileOverlayIdToController.get(tileOverlayId); - if (tileOverlayController == null) { - return null; - } - return tileOverlayController.getTileOverlayInfo(); - } - - private void addTileOverlay(Map tileOverlayOptions) { - if (tileOverlayOptions == null) { - return; - } - TileOverlayBuilder tileOverlayOptionsBuilder = new TileOverlayBuilder(); - String tileOverlayId = - Convert.interpretTileOverlayOptions(tileOverlayOptions, tileOverlayOptionsBuilder); - TileProviderController tileProviderController = - new TileProviderController(methodChannel, tileOverlayId); - tileOverlayOptionsBuilder.setTileProvider(tileProviderController); - TileOverlayOptions options = tileOverlayOptionsBuilder.build(); - TileOverlay tileOverlay = googleMap.addTileOverlay(options); - TileOverlayController tileOverlayController = new TileOverlayController(tileOverlay); - tileOverlayIdToController.put(tileOverlayId, tileOverlayController); - } - - private void changeTileOverlay(Map tileOverlayOptions) { - if (tileOverlayOptions == null) { - return; - } - String tileOverlayId = getTileOverlayId(tileOverlayOptions); - TileOverlayController tileOverlayController = tileOverlayIdToController.get(tileOverlayId); - if (tileOverlayController != null) { - Convert.interpretTileOverlayOptions(tileOverlayOptions, tileOverlayController); - } - } - - private void removeTileOverlay(String tileOverlayId) { - TileOverlayController tileOverlayController = tileOverlayIdToController.get(tileOverlayId); - if (tileOverlayController != null) { - tileOverlayController.remove(); - tileOverlayIdToController.remove(tileOverlayId); - } - } - - @SuppressWarnings("unchecked") - private static String getTileOverlayId(Map tileOverlay) { - return (String) tileOverlay.get("tileOverlayId"); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileProviderController.java b/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileProviderController.java deleted file mode 100644 index f05d04550994..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/main/java/io/flutter/plugins/googlemaps/TileProviderController.java +++ /dev/null @@ -1,100 +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 android.os.Handler; -import android.os.Looper; -import android.util.Log; -import androidx.annotation.NonNull; -import com.google.android.gms.maps.model.Tile; -import com.google.android.gms.maps.model.TileProvider; -import io.flutter.plugin.common.MethodChannel; -import java.util.Map; -import java.util.concurrent.CountDownLatch; - -class TileProviderController implements TileProvider { - - private static final String TAG = "TileProviderController"; - - private final String tileOverlayId; - private final MethodChannel methodChannel; - private final Handler handler = new Handler(Looper.getMainLooper()); - - TileProviderController(MethodChannel methodChannel, String tileOverlayId) { - this.tileOverlayId = tileOverlayId; - this.methodChannel = methodChannel; - } - - @Override - public Tile getTile(final int x, final int y, final int zoom) { - Worker worker = new Worker(x, y, zoom); - return worker.getTile(); - } - - private final class Worker implements MethodChannel.Result { - - private final CountDownLatch countDownLatch = new CountDownLatch(1); - private final int x; - private final int y; - private final int zoom; - private Map result; - - Worker(int x, int y, int zoom) { - this.x = x; - this.y = y; - this.zoom = zoom; - } - - @NonNull - Tile getTile() { - handler.post( - () -> - methodChannel.invokeMethod( - "tileOverlay#getTile", - Convert.tileOverlayArgumentsToJson(tileOverlayId, x, y, zoom), - this)); - try { - // Because `methodChannel.invokeMethod` is async, we use a `countDownLatch` make it synchronized. - countDownLatch.await(); - } catch (InterruptedException e) { - Log.e( - TAG, - String.format("countDownLatch: can't get tile: x = %d, y= %d, zoom = %d", x, y, zoom), - e); - return TileProvider.NO_TILE; - } - try { - return Convert.interpretTile(result); - } catch (Exception e) { - Log.e(TAG, "Can't parse tile data", e); - return TileProvider.NO_TILE; - } - } - - @Override - public void success(Object data) { - result = (Map) data; - countDownLatch.countDown(); - } - - @Override - public void error(String errorCode, String errorMessage, Object data) { - Log.e( - TAG, - String.format( - "Can't get tile: errorCode = %s, errorMessage = %s, date = %s", - errorCode, errorCode, data)); - result = null; - countDownLatch.countDown(); - } - - @Override - public void notImplemented() { - Log.e(TAG, "Can't get tile: notImplemented"); - result = null; - countDownLatch.countDown(); - } - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleBuilderTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleBuilderTest.java index 269c35ebd864..6585090e6e26 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleBuilderTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleBuilderTest.java @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import static junit.framework.TestCase.assertEquals; diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleControllerTest.java index 72a8cab626b5..e032dd436d5a 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/CircleControllerTest.java @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import static org.mockito.Mockito.mock; 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/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java new file mode 100644 index 000000000000..3ca78e7674d7 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/MarkersControllerTest.java @@ -0,0 +1,127 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemaps; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import com.google.android.gms.maps.GoogleMap; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.Marker; +import com.google.android.gms.maps.model.MarkerOptions; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodCodec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.mockito.Mockito; + +public class MarkersControllerTest { + + @Test + public void controller_OnMarkerDragStart() { + final MethodChannel methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + final MarkersController controller = new MarkersController(methodChannel); + final GoogleMap googleMap = mock(GoogleMap.class); + controller.setGoogleMap(googleMap); + + final Marker marker = mock(Marker.class); + + final String googleMarkerId = "abc123"; + + when(marker.getId()).thenReturn(googleMarkerId); + when(googleMap.addMarker(any(MarkerOptions.class))).thenReturn(marker); + + final LatLng latLng = new LatLng(1.1, 2.2); + final Map markerOptions = new HashMap(); + markerOptions.put("markerId", googleMarkerId); + + final List markers = Arrays.asList(markerOptions); + controller.addMarkers(markers); + controller.onMarkerDragStart(googleMarkerId, latLng); + + final List points = new ArrayList(); + points.add(latLng.latitude); + points.add(latLng.longitude); + + final Map data = new HashMap<>(); + data.put("markerId", googleMarkerId); + data.put("position", points); + Mockito.verify(methodChannel).invokeMethod("marker#onDragStart", data); + } + + @Test + public void controller_OnMarkerDragEnd() { + final MethodChannel methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + final MarkersController controller = new MarkersController(methodChannel); + final GoogleMap googleMap = mock(GoogleMap.class); + controller.setGoogleMap(googleMap); + + final Marker marker = mock(Marker.class); + + final String googleMarkerId = "abc123"; + + when(marker.getId()).thenReturn(googleMarkerId); + when(googleMap.addMarker(any(MarkerOptions.class))).thenReturn(marker); + + final LatLng latLng = new LatLng(1.1, 2.2); + final Map markerOptions = new HashMap(); + markerOptions.put("markerId", googleMarkerId); + + final List markers = Arrays.asList(markerOptions); + controller.addMarkers(markers); + controller.onMarkerDragEnd(googleMarkerId, latLng); + + final List points = new ArrayList(); + points.add(latLng.latitude); + points.add(latLng.longitude); + + final Map data = new HashMap<>(); + data.put("markerId", googleMarkerId); + data.put("position", points); + Mockito.verify(methodChannel).invokeMethod("marker#onDragEnd", data); + } + + @Test + public void controller_OnMarkerDrag() { + final MethodChannel methodChannel = + spy(new MethodChannel(mock(BinaryMessenger.class), "no-name", mock(MethodCodec.class))); + final MarkersController controller = new MarkersController(methodChannel); + final GoogleMap googleMap = mock(GoogleMap.class); + controller.setGoogleMap(googleMap); + + final Marker marker = mock(Marker.class); + + final String googleMarkerId = "abc123"; + + when(marker.getId()).thenReturn(googleMarkerId); + when(googleMap.addMarker(any(MarkerOptions.class))).thenReturn(marker); + + final LatLng latLng = new LatLng(1.1, 2.2); + final Map markerOptions = new HashMap(); + markerOptions.put("markerId", googleMarkerId); + + final List markers = Arrays.asList(markerOptions); + controller.addMarkers(markers); + controller.onMarkerDrag(googleMarkerId, latLng); + + final List points = new ArrayList(); + points.add(latLng.latitude); + points.add(latLng.longitude); + + final Map data = new HashMap<>(); + data.put("markerId", googleMarkerId); + data.put("position", points); + Mockito.verify(methodChannel).invokeMethod("marker#onDrag", data); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonBuilderTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonBuilderTest.java index c781afc0ede9..644e8982f246 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonBuilderTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonBuilderTest.java @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import static junit.framework.TestCase.assertEquals; diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonControllerTest.java index 29234b6adb42..834c42766e07 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolygonControllerTest.java @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import static org.mockito.Mockito.mock; diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineBuilderTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineBuilderTest.java index 9e2e9e81b829..bf6d06066fbf 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineBuilderTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineBuilderTest.java @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import static junit.framework.TestCase.assertEquals; diff --git a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineControllerTest.java b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineControllerTest.java index bb7653aa2293..acd231623825 100644 --- a/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineControllerTest.java +++ b/packages/google_maps_flutter/google_maps_flutter/android/src/test/java/io/flutter/plugins/googlemaps/PolylineControllerTest.java @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import static org.mockito.Mockito.mock; diff --git a/packages/google_maps_flutter/google_maps_flutter/example/README.md b/packages/google_maps_flutter/google_maps_flutter/example/README.md index b92b9c326143..800387342121 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/README.md +++ b/packages/google_maps_flutter/google_maps_flutter/example/README.md @@ -5,4 +5,4 @@ Demonstrates how to use the google_maps_flutter plugin. ## Getting Started For help getting started with Flutter, view our online -[documentation](https://flutter.dev/). +[documentation](https://flutter.io/). 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..16e93d936838 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 @@ -25,7 +25,7 @@ apply plugin: 'com.android.application' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 29 + compileSdkVersion 28 lintOptions { disable 'InvalidPackage' @@ -33,7 +33,7 @@ android { defaultConfig { applicationId "io.flutter.plugins.googlemapsexample" - minSdkVersion 20 + minSdkVersion 16 targetSdkVersion 28 versionCode flutterVersionCode.toInteger() versionName flutterVersionName @@ -51,24 +51,14 @@ android { signingConfig signingConfigs.debug } } - - testOptions { - unitTests { - includeAndroidResources = true - } - } - - 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' - testImplementation 'com.google.android.gms:play-services-maps:17.0.0' - } } flutter { source '../..' } + +dependencies { + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' +} 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 index 9da7185b8ace..ff39d1ddf55d 100644 --- 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 @@ -1,17 +1,12 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; +import dev.flutter.plugins.e2e.FlutterRunner; import io.flutter.plugins.googlemapsexample.*; import org.junit.Rule; import org.junit.runner.RunWith; -@RunWith(FlutterTestRunner.class) -@SuppressWarnings("deprecation") +@RunWith(FlutterRunner.class) public class EmbeddingV1ActivityTest { @Rule public ActivityTestRule rule = 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/googlemaps/MainActivityTest.java index fccd4c95c3ac..525d2da8d665 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/googlemaps/MainActivityTest.java @@ -1,16 +1,12 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemaps; import androidx.test.rule.ActivityTestRule; -import dev.flutter.plugins.integration_test.FlutterTestRunner; +import dev.flutter.plugins.e2e.FlutterRunner; import io.flutter.embedding.android.FlutterActivity; import org.junit.Rule; import org.junit.runner.RunWith; -@RunWith(FlutterTestRunner.class) +@RunWith(FlutterRunner.class) public class MainActivityTest { @Rule public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.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..40552ddf7be1 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/GoogleMapsTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlemapsexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.googlemaps.GoogleMapsPlugin; +import org.junit.Test; + +public class GoogleMapsTest { + @Test + public void googleMapsPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(GoogleMapsTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(GoogleMapsPlugin.class)); + }); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java new file mode 100644 index 000000000000..93106cff2de1 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java @@ -0,0 +1,19 @@ +package io.flutter.plugins.googlemaps; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.e2e.FlutterRunner; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; +import org.junit.Rule; +import org.junit.runner.RunWith; + +<<<<<<< HEAD:packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemaps/MainActivityTest.java +@RunWith(FlutterRunner.class) +======= +@DartIntegrationTest +@RunWith(FlutterTestRunner.class) +>>>>>>> b401c84200bccd2b4747c2273ea5e13802dd569f:packages/google_maps_flutter/google_maps_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/googlemapsexample/MainActivityTest.java +public class MainActivityTest { + @Rule + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} 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/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 index cecf76a690e0..8d7b3054bf5f 100644 --- 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 @@ -1,20 +1,15 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - package io.flutter.plugins.googlemapsexample; import android.os.Bundle; -import dev.flutter.plugins.integration_test.IntegrationTestPlugin; +import dev.flutter.plugins.e2e.E2EPlugin; +import io.flutter.app.FlutterActivity; import io.flutter.plugins.googlemaps.GoogleMapsPlugin; -@SuppressWarnings("deprecation") -public class EmbeddingV1Activity extends io.flutter.app.FlutterActivity { +public class EmbeddingV1Activity extends 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")); + E2EPlugin.registerWith(registrarFor("dev.flutter.plugins.e2e.E2EPlugin")); } } 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/android/build.gradle b/packages/google_maps_flutter/google_maps_flutter/example/android/build.gradle index 456d020f6e2c..e0d7ae2c11af 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/android/build.gradle +++ b/packages/google_maps_flutter/google_maps_flutter/example/android/build.gradle @@ -1,7 +1,7 @@ buildscript { repositories { google() - mavenCentral() + jcenter() } dependencies { @@ -12,7 +12,7 @@ buildscript { allprojects { repositories { google() - mavenCentral() + jcenter() } } diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 2093134f0bfb..f6a2d6ec291a 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 @@ -9,34 +9,18 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; + 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4510D964F3B1259FEDD3ABA6 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */; }; + 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; + 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */; }; - F7151F21265D7EE50028CB91 /* GoogleMapsUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F20265D7EE50028CB91 /* GoogleMapsUITests.m */; }; - FC8F35FC8CD533B128950487 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - F7151F15265D7ED70028CB91 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; - F7151F23265D7EE50028CB91 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -44,6 +28,8 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, + 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -54,13 +40,14 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 733AFAB37683A9DA7512F09C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -68,15 +55,7 @@ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B7AFC65E3DD5AC60D834D83D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - E52C6A6210A56F027C582EF9 /* 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 = ""; }; EA0E91726245EDC22B97E8B9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - F7151F10265D7ED70028CB91 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsTests.m; sourceTree = ""; }; - F7151F14265D7ED70028CB91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - F7151F1E265D7EE50028CB91 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - F7151F20265D7EE50028CB91 /* GoogleMapsUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsUITests.m; sourceTree = ""; }; - F7151F22265D7EE50028CB91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -84,25 +63,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, + 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, 4510D964F3B1259FEDD3ABA6 /* libPods-Runner.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - F7151F0D265D7ED70028CB91 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - FC8F35FC8CD533B128950487 /* libPods-RunnerTests.a in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - F7151F1B265D7EE50028CB91 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -110,7 +76,6 @@ isa = PBXGroup; children = ( 7755F8F4BABC3D6A0BD4048B /* libPods-Runner.a */, - F267F68029D1A4E2E4C572A7 /* libPods-RunnerTests.a */, ); name = Frameworks; sourceTree = ""; @@ -118,7 +83,9 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -131,8 +98,6 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, - F7151F11265D7ED70028CB91 /* RunnerTests */, - F7151F1F265D7EE50028CB91 /* RunnerUITests */, 97C146EF1CF9000F007C117D /* Products */, A189CFE5474BF8A07908B2E0 /* Pods */, 1E7CF0857EFC88FC263CF3B2 /* Frameworks */, @@ -143,8 +108,6 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - F7151F10265D7ED70028CB91 /* RunnerTests.xctest */, - F7151F1E265D7EE50028CB91 /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -178,30 +141,10 @@ children = ( B7AFC65E3DD5AC60D834D83D /* Pods-Runner.debug.xcconfig */, EA0E91726245EDC22B97E8B9 /* Pods-Runner.release.xcconfig */, - E52C6A6210A56F027C582EF9 /* Pods-RunnerTests.debug.xcconfig */, - 733AFAB37683A9DA7512F09C /* Pods-RunnerTests.release.xcconfig */, ); name = Pods; sourceTree = ""; }; - F7151F11265D7ED70028CB91 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */, - F7151F14265D7ED70028CB91 /* Info.plist */, - ); - path = RunnerTests; - sourceTree = ""; - }; - F7151F1F265D7EE50028CB91 /* RunnerUITests */ = { - isa = PBXGroup; - children = ( - F7151F20265D7EE50028CB91 /* GoogleMapsUITests.m */, - F7151F22265D7EE50028CB91 /* Info.plist */, - ); - path = RunnerUITests; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -216,6 +159,7 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + FE7DE34E225BB9A5F4DB58C6 /* [CP] Embed Pods Frameworks */, BB6BD9A1101E970BEF85B6D2 /* [CP] Copy Pods Resources */, ); buildRules = ( @@ -227,43 +171,6 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; - F7151F0F265D7ED70028CB91 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = F7151F19265D7ED70028CB91 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - D067548A17DC238B80D2BD12 /* [CP] Check Pods Manifest.lock */, - F7151F0C265D7ED70028CB91 /* Sources */, - F7151F0D265D7ED70028CB91 /* Frameworks */, - F7151F0E265D7ED70028CB91 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - F7151F16265D7ED70028CB91 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = F7151F10265D7ED70028CB91 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - F7151F1D265D7EE50028CB91 /* RunnerUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = F7151F25265D7EE50028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; - buildPhases = ( - F7151F1A265D7EE50028CB91 /* Sources */, - F7151F1B265D7EE50028CB91 /* Frameworks */, - F7151F1C265D7EE50028CB91 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - F7151F24265D7EE50028CB91 /* PBXTargetDependency */, - ); - name = RunnerUITests; - productName = RunnerUITests; - productReference = F7151F1E265D7EE50028CB91 /* RunnerUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -271,21 +178,11 @@ isa = PBXProject; attributes = { LastUpgradeCheck = 1100; - ORGANIZATIONNAME = "The Flutter Authors"; + ORGANIZATIONNAME = "The Chromium Authors"; TargetAttributes = { 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; }; - F7151F0F265D7ED70028CB91 = { - CreatedOnToolsVersion = 12.5; - ProvisioningStyle = Automatic; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - F7151F1D265D7EE50028CB91 = { - CreatedOnToolsVersion = 12.5; - ProvisioningStyle = Automatic; - TestTargetID = 97C146ED1CF9000F007C117D; - }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -302,8 +199,6 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - F7151F0F265D7ED70028CB91 /* RunnerTests */, - F7151F1D265D7EE50028CB91 /* RunnerUITests */, ); }; /* End PBXProject section */ @@ -320,20 +215,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F7151F0E265D7ED70028CB91 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - F7151F1C265D7EE50028CB91 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -349,7 +230,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; }; 74BF216DF17B0C7F983459BD /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -389,38 +270,28 @@ files = ( ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", - "${PODS_ROOT}/GoogleMaps/Maps/Frameworks/GoogleMaps.framework/Resources/GoogleMaps.bundle", ); name = "[CP] Copy Pods Resources"; outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleMaps.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; - D067548A17DC238B80D2BD12 /* [CP] Check Pods Manifest.lock */ = { + FE7DE34E225BB9A5F4DB58C6 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( ); + name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -436,37 +307,8 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F7151F0C265D7ED70028CB91 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - F7151F1A265D7EE50028CB91 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F7151F21265D7EE50028CB91 /* GoogleMapsUITests.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - F7151F16265D7ED70028CB91 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = F7151F15265D7ED70028CB91 /* PBXContainerItemProxy */; - }; - F7151F24265D7EE50028CB91 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = F7151F23265D7EE50028CB91 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -489,6 +331,7 @@ /* Begin XCBuildConfiguration section */ 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -545,6 +388,7 @@ }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -609,7 +453,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.googleMobileMapsExample; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.googleMobileMapsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Debug; @@ -630,67 +474,11 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.googleMobileMapsExample; + PRODUCT_BUNDLE_IDENTIFIER = io.flutter.plugins.googleMobileMapsExample; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; }; - F7151F17265D7ED70028CB91 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = E52C6A6210A56F027C582EF9 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - INFOPLIST_FILE = RunnerTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; - }; - name = Debug; - }; - F7151F18265D7ED70028CB91 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 733AFAB37683A9DA7512F09C /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - INFOPLIST_FILE = RunnerTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; - }; - name = Release; - }; - F7151F26265D7EE50028CB91 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - INFOPLIST_FILE = RunnerUITests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - TEST_TARGET_NAME = Runner; - }; - name = Debug; - }; - F7151F27265D7EE50028CB91 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - INFOPLIST_FILE = RunnerUITests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - TEST_TARGET_NAME = Runner; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -712,24 +500,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F7151F19265D7ED70028CB91 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F7151F17265D7ED70028CB91 /* Debug */, - F7151F18265D7ED70028CB91 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - F7151F25265D7EE50028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F7151F26265D7EE50028CB91 /* Debug */, - F7151F27265D7EE50028CB91 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 919434a6254f..1d526a16ed0f 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "group:Runner.xcodeproj"> diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index afdb55fdfbdd..3bb3697ef41c 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -37,26 +37,6 @@ - - - - - - - - #import diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/AppDelegate.m b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/AppDelegate.m index d050cf771c8f..6896c5c190b1 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/AppDelegate.m +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/AppDelegate.m @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter 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 "AppDelegate.h" #import "GeneratedPluginRegistrant.h" diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/Info.plist b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/Info.plist index 0fa9c73c5d42..372490e1a367 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/Info.plist +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/Info.plist @@ -47,5 +47,7 @@ UIViewControllerBasedStatusBarAppearance + io.flutter.embedded_views_preview + diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/main.m b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/main.m index f97b9ef5c8a1..dff6597e4513 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/main.m +++ b/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner/main.m @@ -1,7 +1,3 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - #import #import #import "AppDelegate.h" diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerUITests/GoogleMapsUITests.m b/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerUITests/GoogleMapsUITests.m deleted file mode 100644 index f56a2d17e3fe..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerUITests/GoogleMapsUITests.m +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -@import XCTest; -@import os.log; - -@interface GoogleMapsUITests : XCTestCase -@property(nonatomic, strong) XCUIApplication* app; -@end - -@implementation GoogleMapsUITests - -- (void)setUp { - self.continueAfterFailure = NO; - - self.app = [[XCUIApplication alloc] init]; - [self.app launch]; - - [self - addUIInterruptionMonitorWithDescription:@"Permission popups" - handler:^BOOL(XCUIElement* _Nonnull interruptingElement) { - if (@available(iOS 14, *)) { - XCUIElement* locationPermission = - interruptingElement.buttons[@"Allow While Using App"]; - if (![locationPermission - waitForExistenceWithTimeout:30.0]) { - XCTFail(@"Failed due to not able to find " - @"locationPermission button"); - } - [locationPermission tap]; - - } else { - XCUIElement* allow = - interruptingElement.buttons[@"Allow"]; - if (![allow waitForExistenceWithTimeout:30.0]) { - XCTFail(@"Failed due to not able to find Allow button"); - } - [allow tap]; - } - return YES; - }]; -} - -- (void)testUserInterface { - XCUIApplication* app = self.app; - XCUIElement* userInteface = app.staticTexts[@"User interface"]; - if (![userInteface waitForExistenceWithTimeout:30.0]) { - os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); - XCTFail(@"Failed due to not able to find User interface"); - } - [userInteface tap]; - XCUIElement* platformView = app.otherElements[@"platform_view[0]"]; - if (![platformView waitForExistenceWithTimeout:30.0]) { - os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); - XCTFail(@"Failed due to not able to find platform view"); - } - XCUIElement* compass = app.buttons[@"disable compass"]; - if (![compass waitForExistenceWithTimeout:30.0]) { - os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); - XCTFail(@"Failed due to not able to find compass button"); - } - [compass tap]; -} - -@end diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/animate_camera.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/animate_camera.dart index cc5fd257dfd3..37c79d302733 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/animate_camera.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/animate_camera.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -26,7 +26,7 @@ class AnimateCamera extends StatefulWidget { } class AnimateCameraState extends State { - GoogleMapController? mapController; + GoogleMapController mapController; void _onMapCreated(GoogleMapController controller) { mapController = controller; @@ -54,9 +54,9 @@ class AnimateCameraState extends State { children: [ Column( children: [ - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.newCameraPosition( const CameraPosition( bearing: 270.0, @@ -69,9 +69,9 @@ class AnimateCameraState extends State { }, child: const Text('newCameraPosition'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.newLatLng( const LatLng(56.1725505, 10.1850512), ), @@ -79,9 +79,9 @@ class AnimateCameraState extends State { }, child: const Text('newLatLng'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.newLatLngBounds( LatLngBounds( southwest: const LatLng(-38.483935, 113.248673), @@ -93,9 +93,9 @@ class AnimateCameraState extends State { }, child: const Text('newLatLngBounds'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.newLatLngZoom( const LatLng(37.4231613, -122.087159), 11.0, @@ -104,9 +104,9 @@ class AnimateCameraState extends State { }, child: const Text('newLatLngZoom'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.scrollBy(150.0, -225.0), ); }, @@ -116,9 +116,9 @@ class AnimateCameraState extends State { ), Column( children: [ - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.zoomBy( -0.5, const Offset(30.0, 20.0), @@ -127,33 +127,33 @@ class AnimateCameraState extends State { }, child: const Text('zoomBy with focus'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.zoomBy(-0.5), ); }, child: const Text('zoomBy'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.zoomIn(), ); }, child: const Text('zoomIn'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.zoomOut(), ); }, child: const Text('zoomOut'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.animateCamera( + mapController.animateCamera( CameraUpdate.zoomTo(16.0), ); }, diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/lite_mode.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/lite_mode.dart deleted file mode 100644 index f6d6f54e135a..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/lite_mode.dart +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// ignore_for_file: public_member_api_docs - -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'page.dart'; - -const CameraPosition _kInitialPosition = - CameraPosition(target: LatLng(-33.852, 151.211), zoom: 11.0); - -class LiteModePage extends GoogleMapExampleAppPage { - LiteModePage() : super(const Icon(Icons.map), 'Lite mode'); - - @override - Widget build(BuildContext context) { - return const _LiteModeBody(); - } -} - -class _LiteModeBody extends StatelessWidget { - const _LiteModeBody(); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 30.0), - child: Center( - child: SizedBox( - width: 300.0, - height: 300.0, - child: GoogleMap( - initialCameraPosition: _kInitialPosition, - liteModeEnabled: true, - ), - ), - ), - ), - ); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart index 15b14db0357a..9564142005ac 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/main.dart @@ -1,11 +1,10 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; -import 'package:google_maps_flutter_example/lite_mode.dart'; import 'animate_camera.dart'; import 'map_click.dart'; import 'map_coordinates.dart'; @@ -18,9 +17,9 @@ import 'place_circle.dart'; import 'place_marker.dart'; import 'place_polygon.dart'; import 'place_polyline.dart'; +import 'place_heatmap.dart'; import 'scrolling_map.dart'; import 'snapshot.dart'; -import 'tile_overlay.dart'; final List _allPages = [ MapUiPage(), @@ -34,10 +33,9 @@ final List _allPages = [ PlacePolylinePage(), PlacePolygonPage(), PlaceCirclePage(), + PlaceHeatmapPage(), PaddingPage(), SnapshotPage(), - LiteModePage(), - TileOverlayPage(), ]; class MapsDemo extends StatelessWidget { diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/map_click.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/map_click.dart index a46fc5fba420..029d3a1f187e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/map_click.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/map_click.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -31,9 +31,9 @@ class _MapClickBody extends StatefulWidget { class _MapClickBodyState extends State<_MapClickBody> { _MapClickBodyState(); - GoogleMapController? mapController; - LatLng? _lastTap; - LatLng? _lastLongPress; + GoogleMapController mapController; + LatLng _lastTap; + LatLng _lastLongPress; @override Widget build(BuildContext context) { diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/map_coordinates.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/map_coordinates.dart index 99ab16802fea..efdbe016f7c4 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/map_coordinates.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/map_coordinates.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -31,7 +31,7 @@ class _MapCoordinatesBody extends StatefulWidget { class _MapCoordinatesBodyState extends State<_MapCoordinatesBody> { _MapCoordinatesBodyState(); - GoogleMapController? mapController; + GoogleMapController mapController; LatLngBounds _visibleRegion = LatLngBounds( southwest: const LatLng(0, 0), northeast: const LatLng(0, 0), @@ -83,11 +83,11 @@ class _MapCoordinatesBodyState extends State<_MapCoordinatesBody> { Widget _getVisibleRegionButton() { return Padding( padding: const EdgeInsets.all(8.0), - child: ElevatedButton( + child: RaisedButton( child: const Text('Get Visible Region Bounds'), onPressed: () async { final LatLngBounds visibleRegion = - await mapController!.getVisibleRegion(); + await mapController.getVisibleRegion(); setState(() { _visibleRegion = visibleRegion; }); diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/map_ui.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/map_ui.dart index 2e0d2d188a3f..051d658ddff8 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/map_ui.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/map_ui.dart @@ -1,12 +1,12 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show rootBundle; import 'package:google_maps_flutter/google_maps_flutter.dart'; +import 'package:flutter/services.dart' show rootBundle; import 'page.dart'; @@ -56,7 +56,7 @@ class MapUiBodyState extends State { bool _myLocationEnabled = true; bool _myTrafficEnabled = false; bool _myLocationButtonEnabled = true; - late GoogleMapController _controller; + GoogleMapController _controller; bool _nightMode = false; @override @@ -70,7 +70,7 @@ class MapUiBodyState extends State { } Widget _compassToggler() { - return TextButton( + return FlatButton( child: Text('${_compassEnabled ? 'disable' : 'enable'} compass'), onPressed: () { setState(() { @@ -81,7 +81,7 @@ class MapUiBodyState extends State { } Widget _mapToolbarToggler() { - return TextButton( + return FlatButton( child: Text('${_mapToolbarEnabled ? 'disable' : 'enable'} map toolbar'), onPressed: () { setState(() { @@ -92,7 +92,7 @@ class MapUiBodyState extends State { } Widget _latLngBoundsToggler() { - return TextButton( + return FlatButton( child: Text( _cameraTargetBounds.bounds == null ? 'bound camera target' @@ -109,7 +109,7 @@ class MapUiBodyState extends State { } Widget _zoomBoundsToggler() { - return TextButton( + return FlatButton( child: Text(_minMaxZoomPreference.minZoom == null ? 'bound zoom' : 'release zoom'), @@ -126,7 +126,7 @@ class MapUiBodyState extends State { Widget _mapTypeCycler() { final MapType nextType = MapType.values[(_mapType.index + 1) % MapType.values.length]; - return TextButton( + return FlatButton( child: Text('change map type to $nextType'), onPressed: () { setState(() { @@ -137,7 +137,7 @@ class MapUiBodyState extends State { } Widget _rotateToggler() { - return TextButton( + return FlatButton( child: Text('${_rotateGesturesEnabled ? 'disable' : 'enable'} rotate'), onPressed: () { setState(() { @@ -148,7 +148,7 @@ class MapUiBodyState extends State { } Widget _scrollToggler() { - return TextButton( + return FlatButton( child: Text('${_scrollGesturesEnabled ? 'disable' : 'enable'} scroll'), onPressed: () { setState(() { @@ -159,7 +159,7 @@ class MapUiBodyState extends State { } Widget _tiltToggler() { - return TextButton( + return FlatButton( child: Text('${_tiltGesturesEnabled ? 'disable' : 'enable'} tilt'), onPressed: () { setState(() { @@ -170,7 +170,7 @@ class MapUiBodyState extends State { } Widget _zoomToggler() { - return TextButton( + return FlatButton( child: Text('${_zoomGesturesEnabled ? 'disable' : 'enable'} zoom'), onPressed: () { setState(() { @@ -181,7 +181,7 @@ class MapUiBodyState extends State { } Widget _zoomControlsToggler() { - return TextButton( + return FlatButton( child: Text('${_zoomControlsEnabled ? 'disable' : 'enable'} zoom controls'), onPressed: () { @@ -193,7 +193,7 @@ class MapUiBodyState extends State { } Widget _indoorViewToggler() { - return TextButton( + return FlatButton( child: Text('${_indoorViewEnabled ? 'disable' : 'enable'} indoor'), onPressed: () { setState(() { @@ -204,9 +204,9 @@ class MapUiBodyState extends State { } Widget _myLocationToggler() { - return TextButton( + return FlatButton( child: Text( - '${_myLocationEnabled ? 'disable' : 'enable'} my location marker'), + '${_myLocationButtonEnabled ? 'disable' : 'enable'} my location button'), onPressed: () { setState(() { _myLocationEnabled = !_myLocationEnabled; @@ -216,7 +216,7 @@ class MapUiBodyState extends State { } Widget _myLocationButtonToggler() { - return TextButton( + return FlatButton( child: Text( '${_myLocationButtonEnabled ? 'disable' : 'enable'} my location button'), onPressed: () { @@ -228,7 +228,7 @@ class MapUiBodyState extends State { } Widget _myTrafficToggler() { - return TextButton( + return FlatButton( child: Text('${_myTrafficEnabled ? 'disable' : 'enable'} my traffic'), onPressed: () { setState(() { @@ -249,10 +249,11 @@ class MapUiBodyState extends State { }); } - // Should only be called if _isMapCreated is true. Widget _nightModeToggler() { - assert(_isMapCreated); - return TextButton( + if (!_isMapCreated) { + return null; + } + return FlatButton( child: Text('${_nightMode ? 'disable' : 'enable'} night mode'), onPressed: () { if (_nightMode) { diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/marker_icons.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/marker_icons.dart index da57b83a7e4f..e0fcc427c1d6 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/marker_icons.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/marker_icons.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -29,8 +29,8 @@ class MarkerIconsBody extends StatefulWidget { const LatLng _kMapCenter = LatLng(52.4478, -3.5402); class MarkerIconsBodyState extends State { - GoogleMapController? controller; - BitmapDescriptor? _markerIcon; + GoogleMapController controller; + BitmapDescriptor _markerIcon; @override Widget build(BuildContext context) { @@ -48,7 +48,7 @@ class MarkerIconsBodyState extends State { target: _kMapCenter, zoom: 7.0, ), - markers: {_createMarker()}, + markers: _createMarker(), onMapCreated: _onMapCreated, ), ), @@ -57,25 +57,23 @@ class MarkerIconsBodyState extends State { ); } - Marker _createMarker() { - if (_markerIcon != null) { - return Marker( + Set _createMarker() { + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return [ + Marker( markerId: MarkerId("marker_1"), position: _kMapCenter, - icon: _markerIcon!, - ); - } else { - return Marker( - markerId: MarkerId("marker_1"), - position: _kMapCenter, - ); - } + icon: _markerIcon, + ), + ].toSet(); } Future _createMarkerImageFromAsset(BuildContext context) async { if (_markerIcon == null) { final ImageConfiguration imageConfiguration = - createLocalImageConfiguration(context, size: Size.square(48)); + createLocalImageConfiguration(context); BitmapDescriptor.fromAssetImage( imageConfiguration, 'assets/red_square.png') .then(_updateBitmap); diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/move_camera.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/move_camera.dart index f8274196770d..514a315e03db 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/move_camera.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/move_camera.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -25,7 +25,7 @@ class MoveCamera extends StatefulWidget { } class MoveCameraState extends State { - GoogleMapController? mapController; + GoogleMapController mapController; void _onMapCreated(GoogleMapController controller) { mapController = controller; @@ -53,9 +53,9 @@ class MoveCameraState extends State { children: [ Column( children: [ - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.newCameraPosition( const CameraPosition( bearing: 270.0, @@ -68,9 +68,9 @@ class MoveCameraState extends State { }, child: const Text('newCameraPosition'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.newLatLng( const LatLng(56.1725505, 10.1850512), ), @@ -78,9 +78,9 @@ class MoveCameraState extends State { }, child: const Text('newLatLng'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.newLatLngBounds( LatLngBounds( southwest: const LatLng(-38.483935, 113.248673), @@ -92,9 +92,9 @@ class MoveCameraState extends State { }, child: const Text('newLatLngBounds'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.newLatLngZoom( const LatLng(37.4231613, -122.087159), 11.0, @@ -103,9 +103,9 @@ class MoveCameraState extends State { }, child: const Text('newLatLngZoom'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.scrollBy(150.0, -225.0), ); }, @@ -115,9 +115,9 @@ class MoveCameraState extends State { ), Column( children: [ - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.zoomBy( -0.5, const Offset(30.0, 20.0), @@ -126,33 +126,33 @@ class MoveCameraState extends State { }, child: const Text('zoomBy with focus'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.zoomBy(-0.5), ); }, child: const Text('zoomBy'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.zoomIn(), ); }, child: const Text('zoomIn'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.zoomOut(), ); }, child: const Text('zoomOut'), ), - TextButton( + FlatButton( onPressed: () { - mapController?.moveCamera( + mapController.moveCamera( CameraUpdate.zoomTo(16.0), ); }, diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/padding.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/padding.dart index d90005fa6998..94b60b7758f9 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/padding.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/padding.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -27,7 +27,7 @@ class MarkerIconsBody extends StatefulWidget { const LatLng _kMapCenter = LatLng(52.4478, -3.5402); class MarkerIconsBodyState extends State { - GoogleMapController? controller; + GoogleMapController controller; EdgeInsets _padding = const EdgeInsets.all(0); @@ -147,19 +147,19 @@ class MarkerIconsBodyState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - TextButton( + FlatButton( child: const Text("Set Padding"), onPressed: () { setState(() { _padding = EdgeInsets.fromLTRB( - double.tryParse(_leftController.value.text) ?? 0, - double.tryParse(_topController.value.text) ?? 0, - double.tryParse(_rightController.value.text) ?? 0, - double.tryParse(_bottomController.value.text) ?? 0); + double.tryParse(_leftController.value?.text) ?? 0, + double.tryParse(_topController.value?.text) ?? 0, + double.tryParse(_rightController.value?.text) ?? 0, + double.tryParse(_bottomController.value?.text) ?? 0); }); }, ), - TextButton( + FlatButton( child: const Text("Reset Padding"), onPressed: () { setState(() { diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/page.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/page.dart index fb6eb3260f6d..eaa43fc9f26d 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/page.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/page.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_circle.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_circle.dart index a4953428f088..954d8876d1d5 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_circle.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_circle.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -28,10 +28,10 @@ class PlaceCircleBody extends StatefulWidget { class PlaceCircleBodyState extends State { PlaceCircleBodyState(); - GoogleMapController? controller; + GoogleMapController controller; Map circles = {}; int _circleIdCounter = 1; - CircleId? selectedCircle; + CircleId selectedCircle; // Values when toggling circle color int fillColorsIndex = 0; @@ -62,14 +62,12 @@ class PlaceCircleBodyState extends State { }); } - void _remove(CircleId circleId) { + void _remove() { setState(() { - if (circles.containsKey(circleId)) { - circles.remove(circleId); - } - if (circleId == selectedCircle) { - selectedCircle = null; + if (circles.containsKey(selectedCircle)) { + circles.remove(selectedCircle); } + selectedCircle = null; }); } @@ -102,37 +100,37 @@ class PlaceCircleBodyState extends State { }); } - void _toggleVisible(CircleId circleId) { - final Circle circle = circles[circleId]!; + void _toggleVisible() { + final Circle circle = circles[selectedCircle]; setState(() { - circles[circleId] = circle.copyWith( + circles[selectedCircle] = circle.copyWith( visibleParam: !circle.visible, ); }); } - void _changeFillColor(CircleId circleId) { - final Circle circle = circles[circleId]!; + void _changeFillColor() { + final Circle circle = circles[selectedCircle]; setState(() { - circles[circleId] = circle.copyWith( + circles[selectedCircle] = circle.copyWith( fillColorParam: colors[++fillColorsIndex % colors.length], ); }); } - void _changeStrokeColor(CircleId circleId) { - final Circle circle = circles[circleId]!; + void _changeStrokeColor() { + final Circle circle = circles[selectedCircle]; setState(() { - circles[circleId] = circle.copyWith( + circles[selectedCircle] = circle.copyWith( strokeColorParam: colors[++strokeColorsIndex % colors.length], ); }); } - void _changeStrokeWidth(CircleId circleId) { - final Circle circle = circles[circleId]!; + void _changeStrokeWidth() { + final Circle circle = circles[selectedCircle]; setState(() { - circles[circleId] = circle.copyWith( + circles[selectedCircle] = circle.copyWith( strokeWidthParam: widths[++widthsIndex % widths.length], ); }); @@ -140,7 +138,6 @@ class PlaceCircleBodyState extends State { @override Widget build(BuildContext context) { - final CircleId? selectedId = selectedCircle; return Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -168,43 +165,40 @@ class PlaceCircleBodyState extends State { children: [ Column( children: [ - TextButton( + FlatButton( child: const Text('add'), onPressed: _add, ), - TextButton( + FlatButton( child: const Text('remove'), - onPressed: (selectedId == null) - ? null - : () => _remove(selectedId), + onPressed: (selectedCircle == null) ? null : _remove, ), - TextButton( + FlatButton( child: const Text('toggle visible'), - onPressed: (selectedId == null) - ? null - : () => _toggleVisible(selectedId), + onPressed: + (selectedCircle == null) ? null : _toggleVisible, ), ], ), Column( children: [ - TextButton( + FlatButton( child: const Text('change stroke width'), - onPressed: (selectedId == null) + onPressed: (selectedCircle == null) ? null - : () => _changeStrokeWidth(selectedId), + : _changeStrokeWidth, ), - TextButton( + FlatButton( child: const Text('change stroke color'), - onPressed: (selectedId == null) + onPressed: (selectedCircle == null) ? null - : () => _changeStrokeColor(selectedId), + : _changeStrokeColor, ), - TextButton( + FlatButton( child: const Text('change fill color'), - onPressed: (selectedId == null) + onPressed: (selectedCircle == null) ? null - : () => _changeFillColor(selectedId), + : _changeFillColor, ), ], ) diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_heatmap.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_heatmap.dart new file mode 100644 index 000000000000..b5a21eb9b739 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_heatmap.dart @@ -0,0 +1,248 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +import 'page.dart'; + +class PlaceHeatmapPage extends GoogleMapExampleAppPage { + PlaceHeatmapPage() : super(const Icon(Icons.linear_scale), 'Place heatmap'); + + @override + Widget build(BuildContext context) { + return const PlaceHeatmapBody(); + } +} + +class PlaceHeatmapBody extends StatefulWidget { + const PlaceHeatmapBody(); + + @override + State createState() => PlaceHeatmapBodyState(); +} + +class PlaceHeatmapBodyState extends State { + PlaceHeatmapBodyState(); + + GoogleMapController controller; + Map heatmaps = {}; + int _heatmapIdCounter = 1; + HeatmapId currentHeatmap; + + // Values when changing heatmap gradient. + int gradientsIndex = 0; + List gradients = [ + HeatmapGradient( + colors: [Colors.green, Colors.red], + startPoints: [0.2, 0.7]), + HeatmapGradient( + colors: [Colors.green, Colors.red], + startPoints: [0.8, 0.9]), + HeatmapGradient( + colors: [Colors.green, Colors.red], + startPoints: [0.2, 0.7], + colorMapSize: 8), + HeatmapGradient( + colors: [Colors.blue, Colors.purple], + startPoints: [0.2, 0.7]), + ]; + + // Values when changing heatmap opacity. + int opacitiesIndex = 0; + List opacities = [0.7, 0.3, 1.0]; + + // Values when changing heatmap radius. + int radiiIndex = 0; + List radii = [20, 10, 30, 40, 50]; + + // Values when changing heatmap layer transparency. + int transparenciesIndex = 0; + List transparencies = [1.0, 0.75, 0.5, 0.25]; + + void _onMapCreated(GoogleMapController controller) { + this.controller = controller; + } + + @override + void dispose() { + super.dispose(); + } + + void _remove() { + setState(() { + if (heatmaps.containsKey(currentHeatmap)) { + heatmaps.remove(currentHeatmap); + } + currentHeatmap = null; + }); + } + + void _add() { + final int heatmapCount = heatmaps.length; + + if (heatmapCount == 1) { + return; + } + + final String heatmapIdVal = 'heatmap_id_$_heatmapIdCounter'; + _heatmapIdCounter++; + final HeatmapId heatmapId = HeatmapId(heatmapIdVal); + + final Heatmap heatmap = Heatmap( + heatmapId: heatmapId, + points: _createPoints(), + ); + + setState(() { + heatmaps[heatmapId] = heatmap; + currentHeatmap = heatmapId; + }); + } + + void _changeGradient() { + final Heatmap heatmap = heatmaps[currentHeatmap]; + setState(() { + heatmaps[currentHeatmap] = heatmap.copyWith( + gradientParam: gradients[++gradientsIndex % gradients.length], + ); + }); + } + + void _changeOpacity() { + final Heatmap heatmap = heatmaps[currentHeatmap]; + setState(() { + heatmaps[currentHeatmap] = heatmap.copyWith( + opacityParam: opacities[++opacitiesIndex % opacities.length], + ); + }); + } + + void _changeRadius() { + final Heatmap heatmap = heatmaps[currentHeatmap]; + setState(() { + heatmaps[currentHeatmap] = heatmap.copyWith( + radiusParam: radii[++radiiIndex % radii.length], + ); + }); + } + + void _changeTransparency() { + final Heatmap heatmap = heatmaps[currentHeatmap]; + setState(() { + heatmaps[currentHeatmap] = heatmap.copyWith( + transparencyParam: + transparencies[++transparenciesIndex % transparencies.length], + ); + }); + } + + void _toggleVisible() { + final Heatmap heatmap = heatmaps[currentHeatmap]; + setState(() { + heatmaps[currentHeatmap] = heatmap.copyWith( + visibleParam: !heatmap.visible, + ); + }); + } + + @override + Widget build(BuildContext context) { + final bool iOSorNotSelected = Platform.isIOS || (currentHeatmap == null); + + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: 350.0, + height: 300.0, + child: GoogleMap( + initialCameraPosition: const CameraPosition( + target: LatLng(52.4478, -3.5402), + zoom: 7.0, + ), + heatmaps: Set.of(heatmaps.values), + onMapCreated: _onMapCreated, + ), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + Column( + children: [ + FlatButton( + child: const Text('add'), + onPressed: _add, + ), + FlatButton( + child: const Text('remove'), + onPressed: (currentHeatmap == null) ? null : _remove, + ), + FlatButton( + child: const Text('toggle visible'), + onPressed: + (currentHeatmap == null) ? null : _toggleVisible, + ), + ], + ), + Column( + children: [ + FlatButton( + child: const Text('change gradient'), + onPressed: + (currentHeatmap == null) ? null : _changeGradient, + ), + FlatButton( + child: const Text('change opacity'), + onPressed: + (currentHeatmap == null) ? null : _changeOpacity, + ), + FlatButton( + child: const Text('change radius'), + onPressed: + (currentHeatmap == null) ? null : _changeRadius, + ), + FlatButton( + child: const Text( + 'change transparencies [android only]'), + onPressed: + iOSorNotSelected ? null : _changeTransparency, + ), + ], + ) + ], + ) + ], + ), + ), + ), + ], + ); + } + + List _createPoints() { + final List points = []; + final double offset = _heatmapIdCounter.ceilToDouble(); + points.add(_createWeightedLatLng(51.4816 + offset, -3.1791, 1)); + points.add(_createWeightedLatLng(53.0430 + offset, -2.9925, 1)); + points.add(_createWeightedLatLng(53.1396 + offset, -4.2739, 1)); + points.add(_createWeightedLatLng(52.4153 + offset, -4.0829, 1)); + return points; + } + + WeightedLatLng _createWeightedLatLng(double lat, double lng, int weight) { + return WeightedLatLng(point: LatLng(lat, lng), intensity: weight); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart index 4e9f4c14ebd0..6808e58c199e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_marker.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -35,9 +35,9 @@ class PlaceMarkerBodyState extends State { PlaceMarkerBodyState(); static final LatLng center = const LatLng(-33.86711, 151.1947171); - GoogleMapController? controller; + GoogleMapController controller; Map markers = {}; - MarkerId? selectedMarker; + MarkerId selectedMarker; int _markerIdCounter = 1; void _onMapCreated(GoogleMapController controller) { @@ -50,14 +50,13 @@ class PlaceMarkerBodyState extends State { } void _onMarkerTapped(MarkerId markerId) { - final Marker? tappedMarker = markers[markerId]; + final Marker tappedMarker = markers[markerId]; if (tappedMarker != null) { setState(() { - final MarkerId? previousMarkerId = selectedMarker; - if (previousMarkerId != null && markers.containsKey(previousMarkerId)) { - final Marker resetOld = markers[previousMarkerId]! + if (markers.containsKey(selectedMarker)) { + final Marker resetOld = markers[selectedMarker] .copyWith(iconParam: BitmapDescriptor.defaultMarker); - markers[previousMarkerId] = resetOld; + markers[selectedMarker] = resetOld; } selectedMarker = markerId; final Marker newMarker = tappedMarker.copyWith( @@ -71,14 +70,14 @@ class PlaceMarkerBodyState extends State { } void _onMarkerDragEnd(MarkerId markerId, LatLng newPosition) async { - final Marker? tappedMarker = markers[markerId]; + final Marker tappedMarker = markers[markerId]; if (tappedMarker != null) { await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( actions: [ - TextButton( + FlatButton( child: const Text('OK'), onPressed: () => Navigator.of(context).pop(), ) @@ -127,23 +126,23 @@ class PlaceMarkerBodyState extends State { }); } - void _remove(MarkerId markerId) { + void _remove() { setState(() { - if (markers.containsKey(markerId)) { - markers.remove(markerId); + if (markers.containsKey(selectedMarker)) { + markers.remove(selectedMarker); } }); } - void _changePosition(MarkerId markerId) { - final Marker marker = markers[markerId]!; + void _changePosition() { + final Marker marker = markers[selectedMarker]; final LatLng current = marker.position; final Offset offset = Offset( center.latitude - current.latitude, center.longitude - current.longitude, ); setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( positionParam: LatLng( center.latitude + offset.dy, center.longitude + offset.dx, @@ -152,23 +151,23 @@ class PlaceMarkerBodyState extends State { }); } - void _changeAnchor(MarkerId markerId) { - final Marker marker = markers[markerId]!; + void _changeAnchor() { + final Marker marker = markers[selectedMarker]; final Offset currentAnchor = marker.anchor; final Offset newAnchor = Offset(1.0 - currentAnchor.dy, currentAnchor.dx); setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( anchorParam: newAnchor, ); }); } - Future _changeInfoAnchor(MarkerId markerId) async { - final Marker marker = markers[markerId]!; + Future _changeInfoAnchor() async { + final Marker marker = markers[selectedMarker]; final Offset currentAnchor = marker.infoWindow.anchor; final Offset newAnchor = Offset(1.0 - currentAnchor.dy, currentAnchor.dx); setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( infoWindowParam: marker.infoWindow.copyWith( anchorParam: newAnchor, ), @@ -176,29 +175,29 @@ class PlaceMarkerBodyState extends State { }); } - Future _toggleDraggable(MarkerId markerId) async { - final Marker marker = markers[markerId]!; + Future _toggleDraggable() async { + final Marker marker = markers[selectedMarker]; setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( draggableParam: !marker.draggable, ); }); } - Future _toggleFlat(MarkerId markerId) async { - final Marker marker = markers[markerId]!; + Future _toggleFlat() async { + final Marker marker = markers[selectedMarker]; setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( flatParam: !marker.flat, ); }); } - Future _changeInfo(MarkerId markerId) async { - final Marker marker = markers[markerId]!; - final String newSnippet = marker.infoWindow.snippet! + '*'; + Future _changeInfo() async { + final Marker marker = markers[selectedMarker]; + final String newSnippet = marker.infoWindow.snippet + '*'; setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( infoWindowParam: marker.infoWindow.copyWith( snippetParam: newSnippet, ), @@ -206,40 +205,40 @@ class PlaceMarkerBodyState extends State { }); } - Future _changeAlpha(MarkerId markerId) async { - final Marker marker = markers[markerId]!; + Future _changeAlpha() async { + final Marker marker = markers[selectedMarker]; final double current = marker.alpha; setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( alphaParam: current < 0.1 ? 1.0 : current * 0.75, ); }); } - Future _changeRotation(MarkerId markerId) async { - final Marker marker = markers[markerId]!; + Future _changeRotation() async { + final Marker marker = markers[selectedMarker]; final double current = marker.rotation; setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( rotationParam: current == 330.0 ? 0.0 : current + 30.0, ); }); } - Future _toggleVisible(MarkerId markerId) async { - final Marker marker = markers[markerId]!; + Future _toggleVisible() async { + final Marker marker = markers[selectedMarker]; setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( visibleParam: !marker.visible, ); }); } - Future _changeZIndex(MarkerId markerId) async { - final Marker marker = markers[markerId]!; + Future _changeZIndex() async { + final Marker marker = markers[selectedMarker]; final double current = marker.zIndex; setState(() { - markers[markerId] = marker.copyWith( + markers[selectedMarker] = marker.copyWith( zIndexParam: current == 12.0 ? 0.0 : current + 1.0, ); }); @@ -284,7 +283,6 @@ class PlaceMarkerBodyState extends State { @override Widget build(BuildContext context) { - final MarkerId? selectedId = selectedMarker; return Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -299,6 +297,9 @@ class PlaceMarkerBodyState extends State { target: LatLng(-33.852, 151.211), zoom: 11.0, ), + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals markers: Set.of(markers.values), ), ), @@ -312,79 +313,57 @@ class PlaceMarkerBodyState extends State { children: [ Column( children: [ - TextButton( + FlatButton( child: const Text('add'), onPressed: _add, ), - TextButton( + FlatButton( child: const Text('remove'), - onPressed: selectedId == null - ? null - : () => _remove(selectedId), + onPressed: _remove, ), - TextButton( + FlatButton( child: const Text('change info'), - onPressed: selectedId == null - ? null - : () => _changeInfo(selectedId), + onPressed: _changeInfo, ), - TextButton( + FlatButton( child: const Text('change info anchor'), - onPressed: selectedId == null - ? null - : () => _changeInfoAnchor(selectedId), + onPressed: _changeInfoAnchor, ), ], ), Column( children: [ - TextButton( + FlatButton( child: const Text('change alpha'), - onPressed: selectedId == null - ? null - : () => _changeAlpha(selectedId), + onPressed: _changeAlpha, ), - TextButton( + FlatButton( child: const Text('change anchor'), - onPressed: selectedId == null - ? null - : () => _changeAnchor(selectedId), + onPressed: _changeAnchor, ), - TextButton( + FlatButton( child: const Text('toggle draggable'), - onPressed: selectedId == null - ? null - : () => _toggleDraggable(selectedId), + onPressed: _toggleDraggable, ), - TextButton( + FlatButton( child: const Text('toggle flat'), - onPressed: selectedId == null - ? null - : () => _toggleFlat(selectedId), + onPressed: _toggleFlat, ), - TextButton( + FlatButton( child: const Text('change position'), - onPressed: selectedId == null - ? null - : () => _changePosition(selectedId), + onPressed: _changePosition, ), - TextButton( + FlatButton( child: const Text('change rotation'), - onPressed: selectedId == null - ? null - : () => _changeRotation(selectedId), + onPressed: _changeRotation, ), - TextButton( + FlatButton( child: const Text('toggle visible'), - onPressed: selectedId == null - ? null - : () => _toggleVisible(selectedId), + onPressed: _toggleVisible, ), - TextButton( + FlatButton( child: const Text('change zIndex'), - onPressed: selectedId == null - ? null - : () => _changeZIndex(selectedId), + onPressed: _changeZIndex, ), // A breaking change to the ImageStreamListener API affects this sample. // I've updates the sample to use the new API, but as we cannot use the new @@ -392,7 +371,7 @@ class PlaceMarkerBodyState extends State { // TODO(amirh): uncomment this one the ImageStream API change makes it to stable. // https://github.com/flutter/flutter/issues/33438 // - // TextButton( + // FlatButton( // child: const Text('set marker icon'), // onPressed: () { // _getAssetIcon(context).then( diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polygon.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polygon.dart index 476084defa75..5713f9a099e6 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polygon.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polygon.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -28,11 +28,10 @@ class PlacePolygonBody extends StatefulWidget { class PlacePolygonBodyState extends State { PlacePolygonBodyState(); - GoogleMapController? controller; + GoogleMapController controller; Map polygons = {}; - Map polygonOffsets = {}; - int _polygonIdCounter = 0; - PolygonId? selectedPolygon; + int _polygonIdCounter = 1; + PolygonId selectedPolygon; // Values when toggling polygon color int strokeColorsIndex = 0; @@ -63,10 +62,10 @@ class PlacePolygonBodyState extends State { }); } - void _remove(PolygonId polygonId) { + void _remove() { setState(() { - if (polygons.containsKey(polygonId)) { - polygons.remove(polygonId); + if (polygons.containsKey(selectedPolygon)) { + polygons.remove(selectedPolygon); } selectedPolygon = null; }); @@ -80,6 +79,7 @@ class PlacePolygonBodyState extends State { } final String polygonIdVal = 'polygon_id_$_polygonIdCounter'; + _polygonIdCounter++; final PolygonId polygonId = PolygonId(polygonIdVal); final Polygon polygon = Polygon( @@ -96,77 +96,56 @@ class PlacePolygonBodyState extends State { setState(() { polygons[polygonId] = polygon; - polygonOffsets[polygonId] = _polygonIdCounter.ceilToDouble(); - // increment _polygonIdCounter to have unique polygon id each time - _polygonIdCounter++; }); } - void _toggleGeodesic(PolygonId polygonId) { - final Polygon polygon = polygons[polygonId]!; + void _toggleGeodesic() { + final Polygon polygon = polygons[selectedPolygon]; setState(() { - polygons[polygonId] = polygon.copyWith( + polygons[selectedPolygon] = polygon.copyWith( geodesicParam: !polygon.geodesic, ); }); } - void _toggleVisible(PolygonId polygonId) { - final Polygon polygon = polygons[polygonId]!; + void _toggleVisible() { + final Polygon polygon = polygons[selectedPolygon]; setState(() { - polygons[polygonId] = polygon.copyWith( + polygons[selectedPolygon] = polygon.copyWith( visibleParam: !polygon.visible, ); }); } - void _changeStrokeColor(PolygonId polygonId) { - final Polygon polygon = polygons[polygonId]!; + void _changeStrokeColor() { + final Polygon polygon = polygons[selectedPolygon]; setState(() { - polygons[polygonId] = polygon.copyWith( + polygons[selectedPolygon] = polygon.copyWith( strokeColorParam: colors[++strokeColorsIndex % colors.length], ); }); } - void _changeFillColor(PolygonId polygonId) { - final Polygon polygon = polygons[polygonId]!; + void _changeFillColor() { + final Polygon polygon = polygons[selectedPolygon]; setState(() { - polygons[polygonId] = polygon.copyWith( + polygons[selectedPolygon] = polygon.copyWith( fillColorParam: colors[++fillColorsIndex % colors.length], ); }); } - void _changeWidth(PolygonId polygonId) { - final Polygon polygon = polygons[polygonId]!; + void _changeWidth() { + final Polygon polygon = polygons[selectedPolygon]; setState(() { - polygons[polygonId] = polygon.copyWith( + polygons[selectedPolygon] = polygon.copyWith( strokeWidthParam: widths[++widthsIndex % widths.length], ); }); } - void _addHoles(PolygonId polygonId) { - final Polygon polygon = polygons[polygonId]!; - setState(() { - polygons[polygonId] = - polygon.copyWith(holesParam: _createHoles(polygonId)); - }); - } - - void _removeHoles(PolygonId polygonId) { - final Polygon polygon = polygons[polygonId]!; - setState(() { - polygons[polygonId] = polygon.copyWith( - holesParam: >[], - ); - }); - } - @override Widget build(BuildContext context) { - final PolygonId? selectedId = selectedPolygon; return Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -194,65 +173,45 @@ class PlacePolygonBodyState extends State { children: [ Column( children: [ - TextButton( + FlatButton( child: const Text('add'), onPressed: _add, ), - TextButton( + FlatButton( child: const Text('remove'), - onPressed: (selectedId == null) - ? null - : () => _remove(selectedId), + onPressed: (selectedPolygon == null) ? null : _remove, ), - TextButton( + FlatButton( child: const Text('toggle visible'), - onPressed: (selectedId == null) - ? null - : () => _toggleVisible(selectedId), + onPressed: + (selectedPolygon == null) ? null : _toggleVisible, ), - TextButton( + FlatButton( child: const Text('toggle geodesic'), - onPressed: (selectedId == null) + onPressed: (selectedPolygon == null) ? null - : () => _toggleGeodesic(selectedId), + : _toggleGeodesic, ), ], ), Column( children: [ - TextButton( - child: const Text('add holes'), - onPressed: (selectedId == null) - ? null - : ((polygons[selectedId]!.holes.isNotEmpty) - ? null - : () => _addHoles(selectedId)), - ), - TextButton( - child: const Text('remove holes'), - onPressed: (selectedId == null) - ? null - : ((polygons[selectedId]!.holes.isEmpty) - ? null - : () => _removeHoles(selectedId)), - ), - TextButton( + FlatButton( child: const Text('change stroke width'), - onPressed: (selectedId == null) - ? null - : () => _changeWidth(selectedId), + onPressed: + (selectedPolygon == null) ? null : _changeWidth, ), - TextButton( + FlatButton( child: const Text('change stroke color'), - onPressed: (selectedId == null) + onPressed: (selectedPolygon == null) ? null - : () => _changeStrokeColor(selectedId), + : _changeStrokeColor, ), - TextButton( + FlatButton( child: const Text('change fill color'), - onPressed: (selectedId == null) + onPressed: (selectedPolygon == null) ? null - : () => _changeFillColor(selectedId), + : _changeFillColor, ), ], ) @@ -276,27 +235,6 @@ class PlacePolygonBodyState extends State { return points; } - List> _createHoles(PolygonId polygonId) { - final List> holes = >[]; - final double offset = polygonOffsets[polygonId]!; - - final List hole1 = []; - hole1.add(_createLatLng(51.8395 + offset, -3.8814)); - hole1.add(_createLatLng(52.0234 + offset, -3.9914)); - hole1.add(_createLatLng(52.1351 + offset, -4.4435)); - hole1.add(_createLatLng(52.0231 + offset, -4.5829)); - holes.add(hole1); - - final List hole2 = []; - hole2.add(_createLatLng(52.2395 + offset, -3.6814)); - hole2.add(_createLatLng(52.4234 + offset, -3.7914)); - hole2.add(_createLatLng(52.5351 + offset, -4.2435)); - hole2.add(_createLatLng(52.4231 + offset, -4.3829)); - holes.add(hole2); - - return holes; - } - LatLng _createLatLng(double lat, double lng) { return LatLng(lat, lng); } diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polyline.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polyline.dart index aeb9bf1b11eb..0c9da634faa7 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polyline.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/place_polyline.dart @@ -1,10 +1,11 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // ignore_for_file: public_member_api_docs -import 'package:flutter/foundation.dart'; +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; @@ -29,10 +30,10 @@ class PlacePolylineBody extends StatefulWidget { class PlacePolylineBodyState extends State { PlacePolylineBodyState(); - GoogleMapController? controller; + GoogleMapController controller; Map polylines = {}; - int _polylineIdCounter = 0; - PolylineId? selectedPolyline; + int _polylineIdCounter = 1; + PolylineId selectedPolyline; // Values when toggling polyline color int colorsIndex = 0; @@ -91,10 +92,10 @@ class PlacePolylineBodyState extends State { }); } - void _remove(PolylineId polylineId) { + void _remove() { setState(() { - if (polylines.containsKey(polylineId)) { - polylines.remove(polylineId); + if (polylines.containsKey(selectedPolyline)) { + polylines.remove(selectedPolyline); } selectedPolyline = null; }); @@ -127,73 +128,73 @@ class PlacePolylineBodyState extends State { }); } - void _toggleGeodesic(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _toggleGeodesic() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( geodesicParam: !polyline.geodesic, ); }); } - void _toggleVisible(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _toggleVisible() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( visibleParam: !polyline.visible, ); }); } - void _changeColor(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _changeColor() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( colorParam: colors[++colorsIndex % colors.length], ); }); } - void _changeWidth(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _changeWidth() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( widthParam: widths[++widthsIndex % widths.length], ); }); } - void _changeJointType(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _changeJointType() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( jointTypeParam: jointTypes[++jointTypesIndex % jointTypes.length], ); }); } - void _changeEndCap(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _changeEndCap() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( endCapParam: endCaps[++endCapsIndex % endCaps.length], ); }); } - void _changeStartCap(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _changeStartCap() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( startCapParam: startCaps[++startCapsIndex % startCaps.length], ); }); } - void _changePattern(PolylineId polylineId) { - final Polyline polyline = polylines[polylineId]!; + void _changePattern() { + final Polyline polyline = polylines[selectedPolyline]; setState(() { - polylines[polylineId] = polyline.copyWith( + polylines[selectedPolyline] = polyline.copyWith( patternsParam: patterns[++patternsIndex % patterns.length], ); }); @@ -201,9 +202,7 @@ class PlacePolylineBodyState extends State { @override Widget build(BuildContext context) { - final bool isIOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; - - final PolylineId? selectedId = selectedPolyline; + final bool iOSorNotSelected = Platform.isIOS || (selectedPolyline == null); return Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -215,7 +214,7 @@ class PlacePolylineBodyState extends State { height: 300.0, child: GoogleMap( initialCameraPosition: const CameraPosition( - target: LatLng(53.1721, -3.5402), + target: LatLng(52.4478, -3.5402), zoom: 7.0, ), polylines: Set.of(polylines.values), @@ -232,67 +231,56 @@ class PlacePolylineBodyState extends State { children: [ Column( children: [ - TextButton( + FlatButton( child: const Text('add'), onPressed: _add, ), - TextButton( + FlatButton( child: const Text('remove'), - onPressed: (selectedId == null) - ? null - : () => _remove(selectedId), + onPressed: + (selectedPolyline == null) ? null : _remove, ), - TextButton( + FlatButton( child: const Text('toggle visible'), - onPressed: (selectedId == null) + onPressed: (selectedPolyline == null) ? null - : () => _toggleVisible(selectedId), + : _toggleVisible, ), - TextButton( + FlatButton( child: const Text('toggle geodesic'), - onPressed: (selectedId == null) + onPressed: (selectedPolyline == null) ? null - : () => _toggleGeodesic(selectedId), + : _toggleGeodesic, ), ], ), Column( children: [ - TextButton( + FlatButton( child: const Text('change width'), - onPressed: (selectedId == null) - ? null - : () => _changeWidth(selectedId), + onPressed: + (selectedPolyline == null) ? null : _changeWidth, ), - TextButton( + FlatButton( child: const Text('change color'), - onPressed: (selectedId == null) - ? null - : () => _changeColor(selectedId), + onPressed: + (selectedPolyline == null) ? null : _changeColor, ), - TextButton( + FlatButton( child: const Text('change start cap [Android only]'), - onPressed: isIOS || (selectedId == null) - ? null - : () => _changeStartCap(selectedId), + onPressed: iOSorNotSelected ? null : _changeStartCap, ), - TextButton( + FlatButton( child: const Text('change end cap [Android only]'), - onPressed: isIOS || (selectedId == null) - ? null - : () => _changeEndCap(selectedId), + onPressed: iOSorNotSelected ? null : _changeEndCap, ), - TextButton( + FlatButton( child: const Text('change joint type [Android only]'), - onPressed: isIOS || (selectedId == null) - ? null - : () => _changeJointType(selectedId), + onPressed: iOSorNotSelected ? null : _changeJointType, ), - TextButton( + FlatButton( child: const Text('change pattern [Android only]'), - onPressed: isIOS || (selectedId == null) - ? null - : () => _changePattern(selectedId), + onPressed: iOSorNotSelected ? null : _changePattern, ), ], ) diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/scrolling_map.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/scrolling_map.dart index 9611d36bc8e8..2aa1243fd27c 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/scrolling_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/scrolling_map.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -48,12 +48,15 @@ class ScrollingMapBody extends StatelessWidget { target: center, zoom: 11.0, ), - gestureRecognizers: // - >{ + gestureRecognizers: + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + >[ Factory( () => EagerGestureRecognizer(), ), - }, + ].toSet(), ), ), ), @@ -81,25 +84,34 @@ class ScrollingMapBody extends StatelessWidget { target: center, zoom: 11.0, ), - markers: { - Marker( - markerId: MarkerId("test_marker_id"), - position: LatLng( - center.latitude, - center.longitude, - ), - infoWindow: const InfoWindow( - title: 'An interesting location', - snippet: '*', - ), - ), - }, - gestureRecognizers: < - Factory>{ + markers: + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + Set.of( + [ + Marker( + markerId: MarkerId("test_marker_id"), + position: LatLng( + center.latitude, + center.longitude, + ), + infoWindow: const InfoWindow( + title: 'An interesting location', + snippet: '*', + ), + ) + ], + ), + gestureRecognizers: + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + >[ Factory( () => ScaleGestureRecognizer(), ), - }, + ].toSet(), ), ), ), diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/snapshot.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/snapshot.dart index c85048f5b5aa..872060d86039 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/snapshot.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/lib/snapshot.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -30,8 +30,8 @@ class _SnapshotBody extends StatefulWidget { } class _SnapshotBodyState extends State<_SnapshotBody> { - GoogleMapController? _mapController; - Uint8List? _imageBytes; + GoogleMapController _mapController; + Uint8List _imageBytes; @override Widget build(BuildContext context) { @@ -47,7 +47,7 @@ class _SnapshotBodyState extends State<_SnapshotBody> { initialCameraPosition: _kInitialPosition, ), ), - TextButton( + FlatButton( child: Text('Take a snapshot'), onPressed: () async { final imageBytes = await _mapController?.takeSnapshot(); @@ -59,7 +59,7 @@ class _SnapshotBodyState extends State<_SnapshotBody> { Container( decoration: BoxDecoration(color: Colors.blueGrey[50]), height: 180, - child: _imageBytes != null ? Image.memory(_imageBytes!) : null, + child: _imageBytes != null ? Image.memory(_imageBytes) : null, ), ], ), diff --git a/packages/google_maps_flutter/google_maps_flutter/example/lib/tile_overlay.dart b/packages/google_maps_flutter/google_maps_flutter/example/lib/tile_overlay.dart deleted file mode 100644 index 1d6dd69c186b..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/example/lib/tile_overlay.dart +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// ignore_for_file: public_member_api_docs - -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; - -import 'page.dart'; - -class TileOverlayPage extends GoogleMapExampleAppPage { - TileOverlayPage() : super(const Icon(Icons.map), 'Tile overlay'); - - @override - Widget build(BuildContext context) { - return const TileOverlayBody(); - } -} - -class TileOverlayBody extends StatefulWidget { - const TileOverlayBody(); - - @override - State createState() => TileOverlayBodyState(); -} - -class TileOverlayBodyState extends State { - TileOverlayBodyState(); - - GoogleMapController? controller; - TileOverlay? _tileOverlay; - - void _onMapCreated(GoogleMapController controller) { - this.controller = controller; - } - - @override - void dispose() { - super.dispose(); - } - - void _removeTileOverlay() { - setState(() { - _tileOverlay = null; - }); - } - - void _addTileOverlay() { - final TileOverlay tileOverlay = TileOverlay( - tileOverlayId: TileOverlayId('tile_overlay_1'), - tileProvider: _DebugTileProvider(), - ); - setState(() { - _tileOverlay = tileOverlay; - }); - } - - void _clearTileCache() { - if (_tileOverlay != null && controller != null) { - controller!.clearTileCache(_tileOverlay!.tileOverlayId); - } - } - - @override - Widget build(BuildContext context) { - Set overlays = { - if (_tileOverlay != null) _tileOverlay!, - }; - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: SizedBox( - width: 350.0, - height: 300.0, - child: GoogleMap( - initialCameraPosition: const CameraPosition( - target: LatLng(59.935460, 30.325177), - zoom: 7.0, - ), - tileOverlays: overlays, - onMapCreated: _onMapCreated, - ), - ), - ), - TextButton( - child: const Text('Add tile overlay'), - onPressed: _addTileOverlay, - ), - TextButton( - child: const Text('Remove tile overlay'), - onPressed: _removeTileOverlay, - ), - TextButton( - child: const Text('Clear tile cache'), - onPressed: _clearTileCache, - ), - ], - ); - } -} - -class _DebugTileProvider implements TileProvider { - _DebugTileProvider() { - boxPaint.isAntiAlias = true; - boxPaint.color = Colors.blue; - boxPaint.strokeWidth = 2.0; - boxPaint.style = PaintingStyle.stroke; - } - - static const int width = 100; - static const int height = 100; - static final Paint boxPaint = Paint(); - static final TextStyle textStyle = TextStyle( - color: Colors.red, - fontSize: 20, - ); - - @override - Future getTile(int x, int y, int? zoom) async { - final ui.PictureRecorder recorder = ui.PictureRecorder(); - final Canvas canvas = Canvas(recorder); - final TextSpan textSpan = TextSpan( - text: '$x,$y', - style: textStyle, - ); - final TextPainter textPainter = TextPainter( - text: textSpan, - textDirection: TextDirection.ltr, - ); - textPainter.layout( - minWidth: 0.0, - maxWidth: width.toDouble(), - ); - final Offset offset = const Offset(0, 0); - textPainter.paint(canvas, offset); - canvas.drawRect( - Rect.fromLTRB(0, 0, width.toDouble(), width.toDouble()), boxPaint); - final ui.Picture picture = recorder.endRecording(); - final Uint8List byteData = await picture - .toImage(width, height) - .then((ui.Image image) => - image.toByteData(format: ui.ImageByteFormat.png)) - .then((ByteData? byteData) => byteData!.buffer.asUint8List()); - return Tile(width, height, byteData); - } -} 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..c7b2c5ff6715 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/example/pubspec.yaml @@ -1,33 +1,62 @@ name: google_maps_flutter_example description: Demonstrates how to use the google_maps_flutter plugin. -publish_to: none - -environment: - sdk: '>=2.12.0 <3.0.0' - flutter: ">=1.22.0" dependencies: flutter: sdk: flutter + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^0.1.0 google_maps_flutter: - # When depending on this package from a real application you should use: - # google_maps_flutter: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. path: ../ - flutter_plugin_android_lifecycle: ^2.0.1 + flutter_plugin_android_lifecycle: ^1.0.0 dev_dependencies: flutter_driver: sdk: flutter - integration_test: - sdk: flutter - pedantic: ^1.10.0 + test: ^1.6.0 + e2e: ^0.2.1 + pedantic: ^1.8.0 +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. uses-material-design: true + + # To add assets to your application, add an assets section, like this: assets: - assets/ + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.io/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.io/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.io/custom-fonts/#from-packages diff --git a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_map_inspector.dart b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_map_inspector.dart similarity index 59% rename from packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_map_inspector.dart rename to packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_map_inspector.dart index a4833fe8561d..3583d4fd5b71 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_map_inspector.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_map_inspector.dart @@ -1,6 +1,6 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. +// Copyright 2019, the Chromium project authors. Please see the AUTHORS file +// for details. 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:typed_data'; import 'package:flutter/services.dart'; @@ -16,71 +16,60 @@ class GoogleMapInspector { final MethodChannel _channel; - Future isCompassEnabled() async { + Future isCompassEnabled() async { return await _channel.invokeMethod('map#isCompassEnabled'); } - Future isMapToolbarEnabled() async { + Future isMapToolbarEnabled() async { return await _channel.invokeMethod('map#isMapToolbarEnabled'); } Future getMinMaxZoomLevels() async { final List zoomLevels = - (await _channel.invokeMethod>('map#getMinMaxZoomLevels'))! + (await _channel.invokeMethod>('map#getMinMaxZoomLevels')) .cast(); return MinMaxZoomPreference(zoomLevels[0], zoomLevels[1]); } - Future getZoomLevel() async { - final double? zoomLevel = + Future getZoomLevel() async { + final double zoomLevel = await _channel.invokeMethod('map#getZoomLevel'); return zoomLevel; } - Future isZoomGesturesEnabled() async { + Future isZoomGesturesEnabled() async { return await _channel.invokeMethod('map#isZoomGesturesEnabled'); } - Future isZoomControlsEnabled() async { + Future isZoomControlsEnabled() async { return await _channel.invokeMethod('map#isZoomControlsEnabled'); } - Future isLiteModeEnabled() async { - return await _channel.invokeMethod('map#isLiteModeEnabled'); - } - - Future isRotateGesturesEnabled() async { + Future isRotateGesturesEnabled() async { return await _channel.invokeMethod('map#isRotateGesturesEnabled'); } - Future isTiltGesturesEnabled() async { + Future isTiltGesturesEnabled() async { return await _channel.invokeMethod('map#isTiltGesturesEnabled'); } - Future isScrollGesturesEnabled() async { + Future isScrollGesturesEnabled() async { return await _channel.invokeMethod('map#isScrollGesturesEnabled'); } - Future isMyLocationButtonEnabled() async { + Future isMyLocationButtonEnabled() async { return await _channel.invokeMethod('map#isMyLocationButtonEnabled'); } - Future isTrafficEnabled() async { + Future isTrafficEnabled() async { return await _channel.invokeMethod('map#isTrafficEnabled'); } - Future isBuildingsEnabled() async { + Future isBuildingsEnabled() async { return await _channel.invokeMethod('map#isBuildingsEnabled'); } - Future takeSnapshot() async { + Future takeSnapshot() async { return await _channel.invokeMethod('map#takeSnapshot'); } - - Future?> getTileOverlayInfo(String id) async { - return (await _channel.invokeMapMethod( - 'map#getTileOverlayInfo', { - 'tileOverlayId': id, - })); - } } diff --git a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_maps_test.dart b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_maps_e2e.dart similarity index 73% rename from packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_maps_test.dart rename to packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_maps_e2e.dart index 8bafca15c344..2eccbc4beba6 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/integration_test/google_maps_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_maps_e2e.dart @@ -1,13 +1,12 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. +// Copyright 2019, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; -import 'dart:ui' as ui; -import 'package:integration_test/integration_test.dart'; +import 'package:e2e/e2e.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,7 +20,7 @@ const CameraPosition _kInitialCameraPosition = CameraPosition(target: _kInitialMapCenter, zoom: _kInitialZoomLevel); void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + E2EWidgetsFlutterBinding.ensureInitialized(); testWidgets('testCompassToggle', (WidgetTester tester) async { final Key key = GlobalKey(); @@ -36,14 +35,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? compassEnabled = await inspector.isCompassEnabled(); + bool compassEnabled = await inspector.isCompassEnabled(); expect(compassEnabled, false); await tester.pumpWidget(Directionality( @@ -76,14 +75,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? mapToolbarEnabled = await inspector.isMapToolbarEnabled(); + bool mapToolbarEnabled = await inspector.isMapToolbarEnabled(); expect(mapToolbarEnabled, false); await tester.pumpWidget(Directionality( @@ -116,7 +115,7 @@ void main() { final Key key = GlobalKey(); final Completer inspectorCompleter = Completer(); - late GoogleMapController controller; + GoogleMapController controller; const MinMaxZoomPreference initialZoomLevel = MinMaxZoomPreference(4, 8); const MinMaxZoomPreference finalZoomLevel = MinMaxZoomPreference(6, 10); @@ -130,7 +129,7 @@ void main() { onMapCreated: (GoogleMapController c) async { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(c.channel!); + GoogleMapInspector(c.channel); controller = c; inspectorCompleter.complete(inspector); }, @@ -145,7 +144,7 @@ void main() { } else if (Platform.isAndroid) { await controller.moveCamera(CameraUpdate.zoomTo(15)); await tester.pumpAndSettle(); - double? zoomLevel = await inspector.getZoomLevel(); + double zoomLevel = await inspector.getZoomLevel(); expect(zoomLevel, equals(initialZoomLevel.maxZoom)); await controller.moveCamera(CameraUpdate.zoomTo(1)); @@ -172,7 +171,7 @@ void main() { } else { await controller.moveCamera(CameraUpdate.zoomTo(15)); await tester.pumpAndSettle(); - double? zoomLevel = await inspector.getZoomLevel(); + double zoomLevel = await inspector.getZoomLevel(); expect(zoomLevel, equals(finalZoomLevel.maxZoom)); await controller.moveCamera(CameraUpdate.zoomTo(1)); @@ -196,14 +195,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? zoomGesturesEnabled = await inspector.isZoomGesturesEnabled(); + bool zoomGesturesEnabled = await inspector.isZoomGesturesEnabled(); expect(zoomGesturesEnabled, false); await tester.pumpWidget(Directionality( @@ -235,14 +234,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? zoomControlsEnabled = await inspector.isZoomControlsEnabled(); + bool zoomControlsEnabled = await inspector.isZoomControlsEnabled(); expect(zoomControlsEnabled, Platform.isIOS ? false : true); /// Zoom Controls functionality is not available on iOS at the moment. @@ -264,46 +263,6 @@ void main() { } }); - testWidgets('testLiteModeEnabled', (WidgetTester tester) async { - final Key key = GlobalKey(); - final Completer inspectorCompleter = - Completer(); - - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: _kInitialCameraPosition, - liteModeEnabled: false, - onMapCreated: (GoogleMapController controller) { - final GoogleMapInspector inspector = - // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); - inspectorCompleter.complete(inspector); - }, - ), - )); - - final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? liteModeEnabled = await inspector.isLiteModeEnabled(); - expect(liteModeEnabled, false); - - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: _kInitialCameraPosition, - liteModeEnabled: true, - onMapCreated: (GoogleMapController controller) { - fail("OnMapCreated should get called only once."); - }, - ), - )); - - liteModeEnabled = await inspector.isLiteModeEnabled(); - expect(liteModeEnabled, true); - }, skip: !Platform.isAndroid); - testWidgets('testRotateGesturesEnabled', (WidgetTester tester) async { final Key key = GlobalKey(); final Completer inspectorCompleter = @@ -318,14 +277,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? rotateGesturesEnabled = await inspector.isRotateGesturesEnabled(); + bool rotateGesturesEnabled = await inspector.isRotateGesturesEnabled(); expect(rotateGesturesEnabled, false); await tester.pumpWidget(Directionality( @@ -358,14 +317,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? tiltGesturesEnabled = await inspector.isTiltGesturesEnabled(); + bool tiltGesturesEnabled = await inspector.isTiltGesturesEnabled(); expect(tiltGesturesEnabled, false); await tester.pumpWidget(Directionality( @@ -398,14 +357,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? scrollGesturesEnabled = await inspector.isScrollGesturesEnabled(); + bool scrollGesturesEnabled = await inspector.isScrollGesturesEnabled(); expect(scrollGesturesEnabled, false); await tester.pumpWidget(Directionality( @@ -425,7 +384,6 @@ void main() { }); testWidgets('testInitialCenterLocationAtCenter', (WidgetTester tester) async { - await tester.binding.setSurfaceSize(const Size(800.0, 600.0)); final Completer mapControllerCompleter = Completer(); final Key key = GlobalKey(); @@ -445,7 +403,6 @@ void main() { await mapControllerCompleter.future; await tester.pumpAndSettle(); - // TODO(cyanglaz): Remove this after we added `mapRendered` callback, and `mapControllerCompleter.complete(controller)` above should happen // in `mapRendered`. // https://github.com/flutter/flutter/issues/54758 @@ -471,7 +428,6 @@ void main() { tester.binding.window.devicePixelRatio) .round()); } - await tester.binding.setSurfaceSize(null); }); testWidgets('testGetVisibleRegion', (WidgetTester tester) async { @@ -556,14 +512,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? isTrafficEnabled = await inspector.isTrafficEnabled(); + bool isTrafficEnabled = await inspector.isTrafficEnabled(); expect(isTrafficEnabled, true); await tester.pumpWidget(Directionality( @@ -596,14 +552,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - final bool? isBuildingsEnabled = await inspector.isBuildingsEnabled(); + final bool isBuildingsEnabled = await inspector.isBuildingsEnabled(); expect(isBuildingsEnabled, true); }); @@ -623,14 +579,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - bool? myLocationButtonEnabled = await inspector.isMyLocationButtonEnabled(); + bool myLocationButtonEnabled = await inspector.isMyLocationButtonEnabled(); expect(myLocationButtonEnabled, true); await tester.pumpWidget(Directionality( @@ -666,14 +622,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - final bool? myLocationButtonEnabled = + final bool myLocationButtonEnabled = await inspector.isMyLocationButtonEnabled(); expect(myLocationButtonEnabled, false); }, skip: Platform.isAndroid); @@ -694,14 +650,14 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), )); final GoogleMapInspector inspector = await inspectorCompleter.future; - final bool? myLocationButtonEnabled = + final bool myLocationButtonEnabled = await inspector.isMyLocationButtonEnabled(); expect(myLocationButtonEnabled, true); }, skip: Platform.isAndroid); @@ -954,8 +910,10 @@ void main() { final BitmapDescriptor scaled = await BitmapDescriptor.fromAssetImage( imageConfiguration, 'red_square.png', mipmaps: false); - expect((mip.toJson() as List)[2], 1); - expect((scaled.toJson() as List)[2], 2); + // ignore: invalid_use_of_visible_for_testing_member + expect(mip.toJson()[2], 1); + // ignore: invalid_use_of_visible_for_testing_member + expect(scaled.toJson()[2], 2); }); testWidgets('testTakeSnapshot', (WidgetTester tester) async { @@ -970,7 +928,7 @@ void main() { onMapCreated: (GoogleMapController controller) { final GoogleMapInspector inspector = // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); + GoogleMapInspector(controller.channel); inspectorCompleter.complete(inspector); }, ), @@ -980,253 +938,7 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 3)); final GoogleMapInspector inspector = await inspectorCompleter.future; - final Uint8List? bytes = await inspector.takeSnapshot(); + final Uint8List bytes = await inspector.takeSnapshot(); expect(bytes?.isNotEmpty, true); - }, - // TODO(cyanglaz): un-skip the test when we can test this on CI with API key enabled. - // https://github.com/flutter/flutter/issues/57057 - skip: Platform.isAndroid); - - testWidgets( - 'set tileOverlay correctly', - (WidgetTester tester) async { - Completer inspectorCompleter = - Completer(); - final TileOverlay tileOverlay1 = TileOverlay( - tileOverlayId: TileOverlayId('tile_overlay_1'), - tileProvider: _DebugTileProvider(), - zIndex: 2, - visible: true, - transparency: 0.2, - fadeIn: true, - ); - - final TileOverlay tileOverlay2 = TileOverlay( - tileOverlayId: TileOverlayId('tile_overlay_2'), - tileProvider: _DebugTileProvider(), - zIndex: 1, - visible: false, - transparency: 0.3, - fadeIn: false, - ); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - initialCameraPosition: _kInitialCameraPosition, - tileOverlays: {tileOverlay1, tileOverlay2}, - onMapCreated: (GoogleMapController controller) { - final GoogleMapInspector inspector = - // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); - inspectorCompleter.complete(inspector); - }, - ), - ), - ); - await tester.pumpAndSettle(const Duration(seconds: 3)); - - final GoogleMapInspector inspector = await inspectorCompleter.future; - - Map tileOverlayInfo1 = - (await inspector.getTileOverlayInfo('tile_overlay_1'))!; - Map tileOverlayInfo2 = - (await inspector.getTileOverlayInfo('tile_overlay_2'))!; - - expect(tileOverlayInfo1['visible'], isTrue); - expect(tileOverlayInfo1['fadeIn'], isTrue); - expect(tileOverlayInfo1['transparency'], - moreOrLessEquals(0.2, epsilon: 0.001)); - expect(tileOverlayInfo1['zIndex'], 2); - - expect(tileOverlayInfo2['visible'], isFalse); - expect(tileOverlayInfo2['fadeIn'], isFalse); - expect(tileOverlayInfo2['transparency'], - moreOrLessEquals(0.3, epsilon: 0.001)); - expect(tileOverlayInfo2['zIndex'], 1); - }, - ); - - testWidgets( - 'update tileOverlays correctly', - (WidgetTester tester) async { - Completer inspectorCompleter = - Completer(); - final Key key = GlobalKey(); - final TileOverlay tileOverlay1 = TileOverlay( - tileOverlayId: TileOverlayId('tile_overlay_1'), - tileProvider: _DebugTileProvider(), - zIndex: 2, - visible: true, - transparency: 0.2, - fadeIn: true, - ); - - final TileOverlay tileOverlay2 = TileOverlay( - tileOverlayId: TileOverlayId('tile_overlay_2'), - tileProvider: _DebugTileProvider(), - zIndex: 3, - visible: true, - transparency: 0.5, - fadeIn: true, - ); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: _kInitialCameraPosition, - tileOverlays: {tileOverlay1, tileOverlay2}, - onMapCreated: (GoogleMapController controller) { - final GoogleMapInspector inspector = - // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); - inspectorCompleter.complete(inspector); - }, - ), - ), - ); - - final GoogleMapInspector inspector = await inspectorCompleter.future; - - final TileOverlay tileOverlay1New = TileOverlay( - tileOverlayId: TileOverlayId('tile_overlay_1'), - tileProvider: _DebugTileProvider(), - zIndex: 1, - visible: false, - transparency: 0.3, - fadeIn: false, - ); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: _kInitialCameraPosition, - tileOverlays: {tileOverlay1New}, - onMapCreated: (GoogleMapController controller) { - fail('update: OnMapCreated should get called only once.'); - }, - ), - ), - ); - - await tester.pumpAndSettle(const Duration(seconds: 3)); - - Map tileOverlayInfo1 = - (await inspector.getTileOverlayInfo('tile_overlay_1'))!; - Map? tileOverlayInfo2 = - await inspector.getTileOverlayInfo('tile_overlay_2'); - - expect(tileOverlayInfo1['visible'], isFalse); - expect(tileOverlayInfo1['fadeIn'], isFalse); - expect(tileOverlayInfo1['transparency'], - moreOrLessEquals(0.3, epsilon: 0.001)); - expect(tileOverlayInfo1['zIndex'], 1); - - expect(tileOverlayInfo2, isNull); - }, - ); - - testWidgets( - 'remove tileOverlays correctly', - (WidgetTester tester) async { - Completer inspectorCompleter = - Completer(); - final Key key = GlobalKey(); - final TileOverlay tileOverlay1 = TileOverlay( - tileOverlayId: TileOverlayId('tile_overlay_1'), - tileProvider: _DebugTileProvider(), - zIndex: 2, - visible: true, - transparency: 0.2, - fadeIn: true, - ); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: _kInitialCameraPosition, - tileOverlays: {tileOverlay1}, - onMapCreated: (GoogleMapController controller) { - final GoogleMapInspector inspector = - // ignore: invalid_use_of_visible_for_testing_member - GoogleMapInspector(controller.channel!); - inspectorCompleter.complete(inspector); - }, - ), - ), - ); - - final GoogleMapInspector inspector = await inspectorCompleter.future; - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - key: key, - initialCameraPosition: _kInitialCameraPosition, - onMapCreated: (GoogleMapController controller) { - fail('OnMapCreated should get called only once.'); - }, - ), - ), - ); - - await tester.pumpAndSettle(const Duration(seconds: 3)); - Map? tileOverlayInfo1 = - await inspector.getTileOverlayInfo('tile_overlay_1'); - - expect(tileOverlayInfo1, isNull); - }, - ); -} - -class _DebugTileProvider implements TileProvider { - _DebugTileProvider() { - boxPaint.isAntiAlias = true; - boxPaint.color = Colors.blue; - boxPaint.strokeWidth = 2.0; - boxPaint.style = PaintingStyle.stroke; - } - - static const int width = 100; - static const int height = 100; - static final Paint boxPaint = Paint(); - static final TextStyle textStyle = TextStyle( - color: Colors.red, - fontSize: 20, - ); - - @override - Future getTile(int x, int y, int? zoom) async { - final ui.PictureRecorder recorder = ui.PictureRecorder(); - final Canvas canvas = Canvas(recorder); - final TextSpan textSpan = TextSpan( - text: "$x,$y", - style: textStyle, - ); - final TextPainter textPainter = TextPainter( - text: textSpan, - textDirection: TextDirection.ltr, - ); - textPainter.layout( - minWidth: 0.0, - maxWidth: width.toDouble(), - ); - final Offset offset = const Offset(0, 0); - textPainter.paint(canvas, offset); - canvas.drawRect( - Rect.fromLTRB(0, 0, width.toDouble(), width.toDouble()), boxPaint); - final ui.Picture picture = recorder.endRecording(); - final Uint8List byteData = await picture - .toImage(width, height) - .then((ui.Image image) => - image.toByteData(format: ui.ImageByteFormat.png)) - .then((ByteData? byteData) => byteData!.buffer.asUint8List()); - return Tile(width, height, byteData); - } + }); } diff --git a/packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_maps_e2e_test.dart b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_maps_e2e_test.dart new file mode 100644 index 000000000000..f3aa9e218d82 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/google_maps_e2e_test.dart @@ -0,0 +1,15 @@ +// Copyright 2019, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_driver/flutter_driver.dart'; + +Future main() async { + final FlutterDriver driver = await FlutterDriver.connect(); + final String result = + await driver.requestData(null, timeout: const Duration(minutes: 1)); + await driver.close(); + exit(result == 'pass' ? 0 : 1); +} diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapTileOverlayController.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapTileOverlayController.h deleted file mode 100644 index 356a13faba62..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapTileOverlayController.h +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// Defines map UI options writable from Flutter. -@protocol FLTGoogleMapTileOverlayOptionsSink -- (void)setFadeIn:(BOOL)fadeIn; -- (void)setTransparency:(float)transparency; -- (void)setZIndex:(int)zIndex; -- (void)setVisible:(BOOL)visible; -- (void)setTileSize:(NSInteger)tileSize; -@end - -@interface FLTGoogleMapTileOverlayController : NSObject -- (instancetype)initWithTileLayer:(GMSTileLayer *)tileLayer mapView:(GMSMapView *)mapView; -- (void)removeTileOverlay; -- (void)clearTileCache; -- (NSDictionary *)getTileOverlayInfo; -@end - -@interface FLTTileProviderController : GMSTileLayer -@property(copy, nonatomic, readonly) NSString *tileOverlayId; -- (instancetype)init:(FlutterMethodChannel *)methodChannel tileOverlayId:(NSString *)tileOverlayId; -@end - -@interface FLTTileOverlaysController : NSObject -- (instancetype)init:(FlutterMethodChannel *)methodChannel - mapView:(GMSMapView *)mapView - registrar:(NSObject *)registrar; -- (void)addTileOverlays:(NSArray *)tileOverlaysToAdd; -- (void)changeTileOverlays:(NSArray *)tileOverlaysToChange; -- (void)removeTileOverlayIds:(NSArray *)tileOverlayIdsToRemove; -- (void)clearTileCache:(NSString *)tileOverlayId; -- (nullable NSDictionary *)getTileOverlayInfo:(NSString *)tileverlayId; -@end - -NS_ASSUME_NONNULL_END diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapTileOverlayController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapTileOverlayController.m deleted file mode 100644 index fb391380c92c..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapTileOverlayController.m +++ /dev/null @@ -1,234 +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 "FLTGoogleMapTileOverlayController.h" -#import "JsonConversions.h" - -static void InterpretTileOverlayOptions(NSDictionary* data, - id sink, - NSObject* registrar) { - NSNumber* visible = data[@"visible"]; - if (visible != nil) { - [sink setVisible:visible.boolValue]; - } - - NSNumber* transparency = data[@"transparency"]; - if (transparency != nil) { - [sink setTransparency:transparency.floatValue]; - } - - NSNumber* zIndex = data[@"zIndex"]; - if (zIndex != nil) { - [sink setZIndex:zIndex.intValue]; - } - - NSNumber* fadeIn = data[@"fadeIn"]; - if (fadeIn != nil) { - [sink setFadeIn:fadeIn.boolValue]; - } - - NSNumber* tileSize = data[@"tileSize"]; - if (tileSize != nil) { - [sink setTileSize:tileSize.integerValue]; - } -} - -@interface FLTGoogleMapTileOverlayController () - -@property(strong, nonatomic) GMSTileLayer* layer; -@property(weak, nonatomic) GMSMapView* mapView; - -@end - -@implementation FLTGoogleMapTileOverlayController - -- (instancetype)initWithTileLayer:(GMSTileLayer*)tileLayer mapView:(GMSMapView*)mapView { - self = [super init]; - if (self) { - self.layer = tileLayer; - self.mapView = mapView; - } - return self; -} - -- (void)removeTileOverlay { - self.layer.map = nil; -} - -- (void)clearTileCache { - [self.layer clearTileCache]; -} - -- (NSDictionary*)getTileOverlayInfo { - NSMutableDictionary* info = [[NSMutableDictionary alloc] init]; - BOOL visible = self.layer.map != nil; - info[@"visible"] = @(visible); - info[@"fadeIn"] = @(self.layer.fadeIn); - float transparency = 1.0 - self.layer.opacity; - info[@"transparency"] = @(transparency); - info[@"zIndex"] = @(self.layer.zIndex); - return info; -} - -#pragma mark - FLTGoogleMapTileOverlayOptionsSink methods - -- (void)setFadeIn:(BOOL)fadeIn { - self.layer.fadeIn = fadeIn; -} - -- (void)setTransparency:(float)transparency { - float opacity = 1.0 - transparency; - self.layer.opacity = opacity; -} - -- (void)setVisible:(BOOL)visible { - self.layer.map = visible ? self.mapView : nil; -} - -- (void)setZIndex:(int)zIndex { - self.layer.zIndex = zIndex; -} - -- (void)setTileSize:(NSInteger)tileSize { - self.layer.tileSize = tileSize; -} -@end - -@interface FLTTileProviderController () - -@property(weak, nonatomic) FlutterMethodChannel* methodChannel; -@property(copy, nonatomic, readwrite) NSString* tileOverlayId; - -@end - -@implementation FLTTileProviderController - -- (instancetype)init:(FlutterMethodChannel*)methodChannel tileOverlayId:(NSString*)tileOverlayId { - self = [super init]; - if (self) { - self.methodChannel = methodChannel; - self.tileOverlayId = tileOverlayId; - } - return self; -} - -#pragma mark - GMSTileLayer method - -- (void)requestTileForX:(NSUInteger)x - y:(NSUInteger)y - zoom:(NSUInteger)zoom - receiver:(id)receiver { - [self.methodChannel - invokeMethod:@"tileOverlay#getTile" - arguments:@{ - @"tileOverlayId" : self.tileOverlayId, - @"x" : @(x), - @"y" : @(y), - @"zoom" : @(zoom) - } - result:^(id _Nullable result) { - UIImage* tileImage; - if ([result isKindOfClass:[NSDictionary class]]) { - FlutterStandardTypedData* typedData = (FlutterStandardTypedData*)result[@"data"]; - if (typedData == nil) { - tileImage = kGMSTileLayerNoTile; - } else { - tileImage = [UIImage imageWithData:typedData.data]; - } - } else { - if ([result isKindOfClass:[FlutterError class]]) { - FlutterError* error = (FlutterError*)result; - NSLog(@"Can't get tile: errorCode = %@, errorMessage = %@, details = %@", - [error code], [error message], [error details]); - } - if ([result isKindOfClass:[FlutterMethodNotImplemented class]]) { - NSLog(@"Can't get tile: notImplemented"); - } - tileImage = kGMSTileLayerNoTile; - } - - [receiver receiveTileWithX:x y:y zoom:zoom image:tileImage]; - }]; -} - -@end - -@interface FLTTileOverlaysController () - -@property(strong, nonatomic) NSMutableDictionary* tileOverlayIdToController; -@property(weak, nonatomic) FlutterMethodChannel* methodChannel; -@property(weak, nonatomic) NSObject* registrar; -@property(weak, nonatomic) GMSMapView* mapView; - -@end - -@implementation FLTTileOverlaysController - -- (instancetype)init:(FlutterMethodChannel*)methodChannel - mapView:(GMSMapView*)mapView - registrar:(NSObject*)registrar { - self = [super init]; - if (self) { - self.methodChannel = methodChannel; - self.mapView = mapView; - self.tileOverlayIdToController = [[NSMutableDictionary alloc] init]; - self.registrar = registrar; - } - return self; -} - -- (void)addTileOverlays:(NSArray*)tileOverlaysToAdd { - for (NSDictionary* tileOverlay in tileOverlaysToAdd) { - NSString* tileOverlayId = [FLTTileOverlaysController getTileOverlayId:tileOverlay]; - FLTTileProviderController* tileProvider = - [[FLTTileProviderController alloc] init:self.methodChannel tileOverlayId:tileOverlayId]; - FLTGoogleMapTileOverlayController* controller = - [[FLTGoogleMapTileOverlayController alloc] initWithTileLayer:tileProvider - mapView:self.mapView]; - InterpretTileOverlayOptions(tileOverlay, controller, self.registrar); - self.tileOverlayIdToController[tileOverlayId] = controller; - } -} - -- (void)changeTileOverlays:(NSArray*)tileOverlaysToChange { - for (NSDictionary* tileOverlay in tileOverlaysToChange) { - NSString* tileOverlayId = [FLTTileOverlaysController getTileOverlayId:tileOverlay]; - FLTGoogleMapTileOverlayController* controller = self.tileOverlayIdToController[tileOverlayId]; - if (!controller) { - continue; - } - InterpretTileOverlayOptions(tileOverlay, controller, self.registrar); - } -} -- (void)removeTileOverlayIds:(NSArray*)tileOverlayIdsToRemove { - for (NSString* tileOverlayId in tileOverlayIdsToRemove) { - FLTGoogleMapTileOverlayController* controller = self.tileOverlayIdToController[tileOverlayId]; - if (!controller) { - continue; - } - [controller removeTileOverlay]; - [self.tileOverlayIdToController removeObjectForKey:tileOverlayId]; - } -} - -- (void)clearTileCache:(NSString*)tileOverlayId { - FLTGoogleMapTileOverlayController* controller = self.tileOverlayIdToController[tileOverlayId]; - if (!controller) { - return; - } - [controller clearTileCache]; -} - -- (nullable NSDictionary*)getTileOverlayInfo:(NSString*)tileverlayId { - if (self.tileOverlayIdToController[tileverlayId] == nil) { - return nil; - } - return [self.tileOverlayIdToController[tileverlayId] getTileOverlayInfo]; -} - -+ (NSString*)getTileOverlayId:(NSDictionary*)tileOverlay { - return tileOverlay[@"tileOverlayId"]; -} - -@end diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.h index 953c0557ff20..397bb7d50baa 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.h +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.h @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -6,6 +6,7 @@ #import #import "GoogleMapCircleController.h" #import "GoogleMapController.h" +#import "GoogleMapHeatmapController.h" #import "GoogleMapMarkerController.h" #import "GoogleMapPolygonController.h" #import "GoogleMapPolylineController.h" diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.m index 7ce2cf1c204d..dd4f3aec29d7 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/FLTGoogleMapsPlugin.m @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -14,10 +14,7 @@ @implementation FLTGoogleMapsPlugin { + (void)registerWithRegistrar:(NSObject*)registrar { FLTGoogleMapFactory* googleMapFactory = [[FLTGoogleMapFactory alloc] initWithRegistrar:registrar]; - [registrar registerViewFactory:googleMapFactory - withId:@"plugins.flutter.io/google_maps" - gestureRecognizersBlockingPolicy: - FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded]; + [registrar registerViewFactory:googleMapFactory withId:@"plugins.flutter.io/google_maps"]; } - (FLTGoogleMapController*)mapFromCall:(FlutterMethodCall*)call error:(FlutterError**)error { diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.h index 2e7a9967ebd3..166cf996a572 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.h +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.h @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.m index bdf36484aaf7..6688d4d57695 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapCircleController.m @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.h index a8cebb983347..b6ccc4f70272 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.h +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.h @@ -1,10 +1,11 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import #import #import "GoogleMapCircleController.h" +#import "GoogleMapHeatmapController.h" #import "GoogleMapMarkerController.h" #import "GoogleMapPolygonController.h" #import "GoogleMapPolylineController.h" diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m index be3728753a5d..63dac18982ea 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapController.m @@ -1,9 +1,8 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import "GoogleMapController.h" -#import "FLTGoogleMapTileOverlayController.h" #import "JsonConversions.h" #pragma mark - Conversion of JSON-like values sent via platform channels. Forward declarations. @@ -56,7 +55,7 @@ @implementation FLTGoogleMapController { FLTPolygonsController* _polygonsController; FLTPolylinesController* _polylinesController; FLTCirclesController* _circlesController; - FLTTileOverlaysController* _tileOverlaysController; + FLTHeatmapsController* _heatmapsController; } - (instancetype)initWithFrame:(CGRect)frame @@ -96,9 +95,9 @@ - (instancetype)initWithFrame:(CGRect)frame _circlesController = [[FLTCirclesController alloc] init:_channel mapView:_mapView registrar:registrar]; - _tileOverlaysController = [[FLTTileOverlaysController alloc] init:_channel - mapView:_mapView - registrar:registrar]; + _heatmapsController = [[FLTHeatmapsController alloc] init:_channel + mapView:_mapView + registrar:registrar]; id markersToAdd = args[@"markersToAdd"]; if ([markersToAdd isKindOfClass:[NSArray class]]) { [_markersController addMarkers:markersToAdd]; @@ -115,9 +114,9 @@ - (instancetype)initWithFrame:(CGRect)frame if ([circlesToAdd isKindOfClass:[NSArray class]]) { [_circlesController addCircles:circlesToAdd]; } - id tileOverlaysToAdd = args[@"tileOverlaysToAdd"]; - if ([tileOverlaysToAdd isKindOfClass:[NSArray class]]) { - [_tileOverlaysController addTileOverlays:tileOverlaysToAdd]; + id heatmapsToAdd = args[@"heatmapsToAdd"]; + if ([heatmapsToAdd isKindOfClass:[NSArray class]]) { + [_heatmapsController addHeatmaps:heatmapsToAdd]; } } return self; @@ -307,29 +306,25 @@ - (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { [_circlesController removeCircleIds:circleIdsToRemove]; } result(nil); - } else if ([call.method isEqualToString:@"tileOverlays#update"]) { - id tileOverlaysToAdd = call.arguments[@"tileOverlaysToAdd"]; - if ([tileOverlaysToAdd isKindOfClass:[NSArray class]]) { - [_tileOverlaysController addTileOverlays:tileOverlaysToAdd]; + } else if ([call.method isEqualToString:@"heatmaps#update"]) { + id heatmapsToAdd = call.arguments[@"heatmapsToAdd"]; + if ([heatmapsToAdd isKindOfClass:[NSArray class]]) { + [_heatmapsController addHeatmaps:heatmapsToAdd]; } - id tileOverlaysToChange = call.arguments[@"tileOverlaysToChange"]; - if ([tileOverlaysToChange isKindOfClass:[NSArray class]]) { - [_tileOverlaysController changeTileOverlays:tileOverlaysToChange]; + id heatmapsToChange = call.arguments[@"heatmapsToChange"]; + if ([heatmapsToChange isKindOfClass:[NSArray class]]) { + [_heatmapsController changeHeatmaps:heatmapsToChange]; } - id tileOverlayIdsToRemove = call.arguments[@"tileOverlayIdsToRemove"]; - if ([tileOverlayIdsToRemove isKindOfClass:[NSArray class]]) { - [_tileOverlaysController removeTileOverlayIds:tileOverlayIdsToRemove]; + id heatmapIdsToRemove = call.arguments[@"heatmapIdsToRemove"]; + if ([heatmapIdsToRemove isKindOfClass:[NSArray class]]) { + [_heatmapsController removeHeatmapIds:heatmapIdsToRemove]; } result(nil); - } else if ([call.method isEqualToString:@"tileOverlays#clearTileCache"]) { - id rawTileOverlayId = call.arguments[@"tileOverlayId"]; - [_tileOverlaysController clearTileCache:rawTileOverlayId]; - result(nil); } else if ([call.method isEqualToString:@"map#isCompassEnabled"]) { NSNumber* isCompassEnabled = @(_mapView.settings.compassButton); result(isCompassEnabled); } else if ([call.method isEqualToString:@"map#isMapToolbarEnabled"]) { - NSNumber* isMapToolbarEnabled = @NO; + NSNumber* isMapToolbarEnabled = [NSNumber numberWithBool:NO]; result(isMapToolbarEnabled); } else if ([call.method isEqualToString:@"map#getMinMaxZoomLevels"]) { NSArray* zoomLevels = @[ @(_mapView.minZoom), @(_mapView.maxZoom) ]; @@ -340,7 +335,7 @@ - (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { NSNumber* isZoomGesturesEnabled = @(_mapView.settings.zoomGestures); result(isZoomGesturesEnabled); } else if ([call.method isEqualToString:@"map#isZoomControlsEnabled"]) { - NSNumber* isZoomControlsEnabled = @NO; + NSNumber* isZoomControlsEnabled = [NSNumber numberWithBool:NO]; result(isZoomControlsEnabled); } else if ([call.method isEqualToString:@"map#isTiltGesturesEnabled"]) { NSNumber* isTiltGesturesEnabled = @(_mapView.settings.tiltGestures); @@ -368,9 +363,6 @@ - (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else { result(@[ @(NO), error ]); } - } else if ([call.method isEqualToString:@"map#getTileOverlayInfo"]) { - NSString* rawTileOverlayId = call.arguments[@"tileOverlayId"]; - result([_tileOverlaysController getTileOverlayInfo:rawTileOverlayId]); } else { result(FlutterMethodNotImplemented); } diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapHeatmapController.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapHeatmapController.h new file mode 100644 index 000000000000..f0c8f993a575 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapHeatmapController.h @@ -0,0 +1,33 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import + +// Defines heatmap UI options writable from Flutter. +@protocol FLTGoogleMapHeatmapOptionsSink +- (void)setPoints:(NSArray*)points; +- (void)setGradient:(GMUGradient*)gradient; +- (void)setRadius:(NSUInteger)radius; +- (void)setVisible:(BOOL)visible; +- (void)setOpacity:(double)opacity; +@end + +// Defines heatmap controllable by Flutter. +@interface FLTGoogleMapHeatmapController : NSObject +@property(atomic, readonly) NSString* heatmapId; +- (instancetype)initHeatmapWithHeatmapId:(NSString*)heatmapId mapView:(GMSMapView*)mapView; +- (void)removeHeatmap; +@end + +@interface FLTHeatmapsController : NSObject +- (instancetype)init:(FlutterMethodChannel*)methodChannel + mapView:(GMSMapView*)mapView + registrar:(NSObject*)registrar; +- (void)addHeatmaps:(NSArray*)heatmapsToAdd; +- (void)changeHeatmaps:(NSArray*)heatmapsToChange; +- (void)removeHeatmapIds:(NSArray*)heatmapIdsToRemove; +- (bool)hasHeatmapWithId:(NSString*)heatmapId; +@end diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapHeatmapController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapHeatmapController.m new file mode 100644 index 000000000000..fffd1b774277 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapHeatmapController.m @@ -0,0 +1,188 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "GoogleMapHeatmapController.h" +#import "JsonConversions.h" + +@implementation FLTGoogleMapHeatmapController { + GMUHeatmapTileLayer* _heatmapLayer; + GMSMapView* _mapView; +} +- (instancetype)initHeatmapWithHeatmapId:(NSString*)heatmapId mapView:(GMSMapView*)mapView { + self = [super init]; + if (self) { + _heatmapLayer = [[GMUHeatmapTileLayer alloc] init]; + _heatmapLayer.map = mapView; + _mapView = mapView; + _heatmapId = heatmapId; + } + return self; +} + +- (void)removeHeatmap { + _heatmapLayer.map = nil; +} + +#pragma mark - FLTGoogleMapHeatmapOptionsSink methods + +- (void)setVisible:(BOOL)visible { + _heatmapLayer.map = visible ? _mapView : nil; +} +- (void)setRadius:(NSUInteger)radius { + [_heatmapLayer setRadius:radius]; + [_heatmapLayer clearTileCache]; +} +- (void)setGradient:(GMUGradient*)gradient { + [_heatmapLayer setGradient:gradient]; + [_heatmapLayer clearTileCache]; +} +- (void)setPoints:(NSArray*)points { + [_heatmapLayer setWeightedData:points]; + [_heatmapLayer clearTileCache]; +} +- (void)setOpacity:(double)opacity { + _heatmapLayer.opacity = opacity; + [_heatmapLayer clearTileCache]; +} + +@end + +static int ToInt(NSNumber* data) { return [FLTGoogleMapJsonConversions toInt:data]; } + +static double ToDouble(NSNumber* data) { return [FLTGoogleMapJsonConversions toDouble:data]; } + +static BOOL ToBool(NSNumber* data) { return [FLTGoogleMapJsonConversions toBool:data]; } + +static NSArray* ToPoints(NSArray* data) { + NSMutableArray* points = [[NSMutableArray alloc] init]; + for (unsigned i = 0; i < [data count]; i++) { + NSNumber* latitude = data[i][0][0]; + NSNumber* longitude = data[i][0][1]; + NSNumber* intensity = data[i][1]; + GMUWeightedLatLng* weightedPoint = [[GMUWeightedLatLng alloc] + initWithCoordinate:CLLocationCoordinate2DMake( + [FLTGoogleMapJsonConversions toDouble:latitude], + [FLTGoogleMapJsonConversions toDouble:longitude]) + intensity:[FLTGoogleMapJsonConversions toFloat:intensity]]; + [points addObject:weightedPoint]; + } + + return points; +} + +static NSArray* ToColors(NSArray* data) { + NSMutableArray* colors = [[NSMutableArray alloc] init]; + for (unsigned i = 0; i < [data count]; i++) { + UIColor* color = data[i]; + [colors addObject:color]; + } + + return colors; +} + +static NSArray* ToStartsPoints(NSArray* data) { + NSMutableArray* startPoints = [[NSMutableArray alloc] init]; + for (unsigned i = 0; i < [data count]; i++) { + NSNumber* startPoint = data[i]; + [startPoints addObject:startPoint]; + } + + return startPoints; +} + +static GMUGradient* ToGradient(NSArray* data) { + GMUGradient* gradient = [[GMUGradient alloc] initWithColors:ToColors(data[0]) + startPoints:ToStartsPoints(data[1]) + colorMapSize:ToInt(data[2])]; + return gradient; +} + +static void InterpretHeatmapOptions(NSDictionary* data, id sink, + NSObject* registrar) { + NSNumber* visible = data[@"visible"]; + if (visible != nil) { + [sink setVisible:ToBool(visible)]; + } + + NSNumber* radius = data[@"radius"]; + if (radius != nil) { + [sink setRadius:ToInt(radius)]; + } + + NSArray* gradient = data[@"gradient"]; + if (gradient != nil) { + [sink setGradient:ToGradient(gradient)]; + } + + NSArray* points = data[@"points"]; + if (points) { + [sink setPoints:ToPoints(points)]; + } + + NSNumber* opacity = data[@"opacity"]; + if (opacity) { + [sink setOpacity:ToDouble(opacity)]; + } +} + +@implementation FLTHeatmapsController { + NSMutableDictionary* _heatmapIdToController; + FlutterMethodChannel* _methodChannel; + NSObject* _registrar; + GMSMapView* _mapView; +} +- (instancetype)init:(FlutterMethodChannel*)methodChannel + mapView:(GMSMapView*)mapView + registrar:(NSObject*)registrar { + self = [super init]; + if (self) { + _methodChannel = methodChannel; + _mapView = mapView; + _heatmapIdToController = [NSMutableDictionary dictionaryWithCapacity:1]; + _registrar = registrar; + } + return self; +} +- (void)addHeatmaps:(NSArray*)heatmapsToAdd { + for (NSDictionary* heatmap in heatmapsToAdd) { + NSString* heatmapId = [FLTHeatmapsController getHeatmapId:heatmap]; + FLTGoogleMapHeatmapController* controller = + [[FLTGoogleMapHeatmapController alloc] initHeatmapWithHeatmapId:heatmapId mapView:_mapView]; + InterpretHeatmapOptions(heatmap, controller, _registrar); + _heatmapIdToController[heatmapId] = controller; + } +} +- (void)changeHeatmaps:(NSArray*)heatmapsToChange { + for (NSDictionary* heatmap in heatmapsToChange) { + NSString* heatmapId = [FLTHeatmapsController getHeatmapId:heatmap]; + FLTGoogleMapHeatmapController* controller = _heatmapIdToController[heatmapId]; + if (!controller) { + continue; + } + InterpretHeatmapOptions(heatmap, controller, _registrar); + } +} +- (void)removeHeatmapIds:(NSArray*)heatmapIdsToRemove { + for (NSString* heatmapId in heatmapIdsToRemove) { + if (!heatmapId) { + continue; + } + FLTGoogleMapHeatmapController* controller = _heatmapIdToController[heatmapId]; + if (!controller) { + continue; + } + [controller removeHeatmap]; + [_heatmapIdToController removeObjectForKey:heatmapId]; + } +} +- (bool)hasHeatmapWithId:(NSString*)heatmapId { + if (!heatmapId) { + return false; + } + return _heatmapIdToController[heatmapId] != nil; +} ++ (NSString*)getHeatmapId:(NSDictionary*)heatmap { + return heatmap[@"heatmapId"]; +} +@end diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h index d3e835435ed9..593d2ff9931b 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.h @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -52,4 +52,4 @@ NS_ASSUME_NONNULL_BEGIN - (void)isMarkerInfoWindowShown:(NSString*)markerId result:(FlutterResult)result; @end -NS_ASSUME_NONNULL_END +NS_ASSUME_NONNULL_END \ No newline at end of file diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m index 6a9fb885afac..cd51b2fd9896 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapMarkerController.m @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.h b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.h index b123ac0a3d68..c7613fde5f93 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.h +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.h @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -13,7 +13,6 @@ - (void)setStrokeColor:(UIColor*)color; - (void)setStrokeWidth:(CGFloat)width; - (void)setPoints:(NSArray*)points; -- (void)setHoles:(NSArray*>*)holes; - (void)setZIndex:(int)zIndex; @end diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.m index 5ad8d4d3bc0e..678d40e3efec 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolygonController.m @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -45,19 +45,6 @@ - (void)setPoints:(NSArray*)points { } _polygon.path = path; } -- (void)setHoles:(NSArray*>*)rawHoles { - NSMutableArray* holes = [[NSMutableArray alloc] init]; - - for (NSArray* points in rawHoles) { - GMSMutablePath* path = [GMSMutablePath path]; - for (CLLocation* location in points) { - [path addCoordinate:location.coordinate]; - } - [holes addObject:path]; - } - - _polygon.holes = holes; -} - (void)setFillColor:(UIColor*)color { _polygon.fillColor = color; @@ -78,10 +65,6 @@ - (void)setStrokeWidth:(CGFloat)width { return [FLTGoogleMapJsonConversions toPoints:data]; } -static NSArray*>* ToHoles(NSArray* data) { - return [FLTGoogleMapJsonConversions toHoles:data]; -} - static UIColor* ToColor(NSNumber* data) { return [FLTGoogleMapJsonConversions toColor:data]; } static void InterpretPolygonOptions(NSDictionary* data, id sink, @@ -106,11 +89,6 @@ static void InterpretPolygonOptions(NSDictionary* data, id*)points; - (void)setZIndex:(int)zIndex; -- (void)setGeodesic:(BOOL)isGeodesic; @end // Defines polyline controllable by Flutter. diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolylineController.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolylineController.m index 8c70d2c161ba..b701a5f3a6b5 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolylineController.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/GoogleMapPolylineController.m @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -52,10 +52,6 @@ - (void)setColor:(UIColor*)color { - (void)setStrokeWidth:(CGFloat)width { _polyline.strokeWidth = width; } - -- (void)setGeodesic:(BOOL)isGeodesic { - _polyline.geodesic = isGeodesic; -} @end static int ToInt(NSNumber* data) { return [FLTGoogleMapJsonConversions toInt:data]; } @@ -99,11 +95,6 @@ static void InterpretPolylineOptions(NSDictionary* data, id*)toPoints:(NSArray*)data; -+ (NSArray*>*)toHoles:(NSArray*)data; @end diff --git a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/JsonConversions.m b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/JsonConversions.m index 592d7e825b38..6381beaee8d2 100644 --- a/packages/google_maps_flutter/google_maps_flutter/ios/Classes/JsonConversions.m +++ b/packages/google_maps_flutter/google_maps_flutter/ios/Classes/JsonConversions.m @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -58,14 +58,4 @@ + (UIColor*)toColor:(NSNumber*)numberColor { return points; } -+ (NSArray*>*)toHoles:(NSArray*)data { - NSMutableArray*>* holes = [[[NSMutableArray alloc] init] init]; - for (unsigned i = 0; i < [data count]; i++) { - NSArray* points = [FLTGoogleMapJsonConversions toPoints:data[i]]; - [holes addObject:points]; - } - - return holes; -} - @end 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..e65b23444870 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 @@ -18,6 +18,7 @@ Downloaded by pub (not CocoaPods). s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' s.dependency 'GoogleMaps' + s.dependency 'Google-Maps-iOS-Utils' s.static_framework = true s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart index 93bb0566dd1f..7ad46fdcb9b8 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/google_maps_flutter.dart @@ -1,11 +1,10 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. library google_maps_flutter; import 'dart:async'; -import 'dart:io'; import 'dart:typed_data'; import 'dart:ui'; @@ -13,6 +12,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'package:google_maps_flutter_platform_interface/src/method_channel/method_channel_google_maps_flutter.dart'; @@ -28,6 +28,9 @@ export 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf Cap, Circle, CircleId, + Heatmap, + HeatmapGradient, + HeatmapId, InfoWindow, JointType, LatLng, @@ -43,10 +46,7 @@ export 'package:google_maps_flutter_platform_interface/google_maps_flutter_platf Polyline, PolylineId, ScreenCoordinate, - Tile, - TileOverlayId, - TileOverlay, - TileProvider; + WeightedLatLng; part 'src/controller.dart'; part 'src/google_map.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart index ba18c5ffc17b..1b9bbb053a38 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/controller.dart @@ -1,9 +1,12 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. part of google_maps_flutter; +final GoogleMapsFlutterPlatform _googleMapsFlutterPlatform = + GoogleMapsFlutterPlatform.instance; + /// Controller for a single GoogleMap instance running on the host platform. class GoogleMapController { /// The mapId for this controller @@ -12,8 +15,8 @@ class GoogleMapController { GoogleMapController._( CameraPosition initialCameraPosition, this._googleMapState, { - required this.mapId, - }) { + @required this.mapId, + }) : assert(_googleMapsFlutterPlatform != null) { _connectStreams(mapId); } @@ -27,7 +30,7 @@ class GoogleMapController { _GoogleMapState googleMapState, ) async { assert(id != null); - await GoogleMapsFlutterPlatform.instance.init(id); + await _googleMapsFlutterPlatform.init(id); return GoogleMapController._( initialCameraPosition, googleMapState, @@ -40,10 +43,9 @@ class GoogleMapController { /// Accessible only for testing. // TODO(dit) https://github.com/flutter/flutter/issues/55504 Remove this getter. @visibleForTesting - MethodChannel? get channel { - if (GoogleMapsFlutterPlatform.instance is MethodChannelGoogleMapsFlutter) { - return (GoogleMapsFlutterPlatform.instance - as MethodChannelGoogleMapsFlutter) + MethodChannel get channel { + if (_googleMapsFlutterPlatform is MethodChannelGoogleMapsFlutter) { + return (_googleMapsFlutterPlatform as MethodChannelGoogleMapsFlutter) .channel(mapId); } return null; @@ -53,40 +55,40 @@ class GoogleMapController { void _connectStreams(int mapId) { if (_googleMapState.widget.onCameraMoveStarted != null) { - GoogleMapsFlutterPlatform.instance + _googleMapsFlutterPlatform .onCameraMoveStarted(mapId: mapId) - .listen((_) => _googleMapState.widget.onCameraMoveStarted!()); + .listen((_) => _googleMapState.widget.onCameraMoveStarted()); } if (_googleMapState.widget.onCameraMove != null) { - GoogleMapsFlutterPlatform.instance.onCameraMove(mapId: mapId).listen( - (CameraMoveEvent e) => _googleMapState.widget.onCameraMove!(e.value)); + _googleMapsFlutterPlatform.onCameraMove(mapId: mapId).listen( + (CameraMoveEvent e) => _googleMapState.widget.onCameraMove(e.value)); } if (_googleMapState.widget.onCameraIdle != null) { - GoogleMapsFlutterPlatform.instance + _googleMapsFlutterPlatform .onCameraIdle(mapId: mapId) - .listen((_) => _googleMapState.widget.onCameraIdle!()); + .listen((_) => _googleMapState.widget.onCameraIdle()); } - GoogleMapsFlutterPlatform.instance + _googleMapsFlutterPlatform .onMarkerTap(mapId: mapId) .listen((MarkerTapEvent e) => _googleMapState.onMarkerTap(e.value)); - GoogleMapsFlutterPlatform.instance.onMarkerDragEnd(mapId: mapId).listen( + _googleMapsFlutterPlatform.onMarkerDragEnd(mapId: mapId).listen( (MarkerDragEndEvent e) => _googleMapState.onMarkerDragEnd(e.value, e.position)); - GoogleMapsFlutterPlatform.instance.onInfoWindowTap(mapId: mapId).listen( + _googleMapsFlutterPlatform.onInfoWindowTap(mapId: mapId).listen( (InfoWindowTapEvent e) => _googleMapState.onInfoWindowTap(e.value)); - GoogleMapsFlutterPlatform.instance + _googleMapsFlutterPlatform .onPolylineTap(mapId: mapId) .listen((PolylineTapEvent e) => _googleMapState.onPolylineTap(e.value)); - GoogleMapsFlutterPlatform.instance + _googleMapsFlutterPlatform .onPolygonTap(mapId: mapId) .listen((PolygonTapEvent e) => _googleMapState.onPolygonTap(e.value)); - GoogleMapsFlutterPlatform.instance + _googleMapsFlutterPlatform .onCircleTap(mapId: mapId) .listen((CircleTapEvent e) => _googleMapState.onCircleTap(e.value)); - GoogleMapsFlutterPlatform.instance + _googleMapsFlutterPlatform .onTap(mapId: mapId) .listen((MapTapEvent e) => _googleMapState.onTap(e.position)); - GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( + _googleMapsFlutterPlatform.onLongPress(mapId: mapId).listen( (MapLongPressEvent e) => _googleMapState.onLongPress(e.position)); } @@ -98,8 +100,8 @@ class GoogleMapController { /// The returned [Future] completes after listeners have been notified. Future _updateMapOptions(Map optionsUpdate) { assert(optionsUpdate != null); - return GoogleMapsFlutterPlatform.instance - .updateMapOptions(optionsUpdate, mapId: mapId); + return _googleMapsFlutterPlatform.updateMapOptions(optionsUpdate, + mapId: mapId); } /// Updates marker configuration. @@ -110,8 +112,8 @@ class GoogleMapController { /// The returned [Future] completes after listeners have been notified. Future _updateMarkers(MarkerUpdates markerUpdates) { assert(markerUpdates != null); - return GoogleMapsFlutterPlatform.instance - .updateMarkers(markerUpdates, mapId: mapId); + return _googleMapsFlutterPlatform.updateMarkers(markerUpdates, + mapId: mapId); } /// Updates polygon configuration. @@ -122,8 +124,8 @@ class GoogleMapController { /// The returned [Future] completes after listeners have been notified. Future _updatePolygons(PolygonUpdates polygonUpdates) { assert(polygonUpdates != null); - return GoogleMapsFlutterPlatform.instance - .updatePolygons(polygonUpdates, mapId: mapId); + return _googleMapsFlutterPlatform.updatePolygons(polygonUpdates, + mapId: mapId); } /// Updates polyline configuration. @@ -134,8 +136,8 @@ class GoogleMapController { /// The returned [Future] completes after listeners have been notified. Future _updatePolylines(PolylineUpdates polylineUpdates) { assert(polylineUpdates != null); - return GoogleMapsFlutterPlatform.instance - .updatePolylines(polylineUpdates, mapId: mapId); + return _googleMapsFlutterPlatform.updatePolylines(polylineUpdates, + mapId: mapId); } /// Updates circle configuration. @@ -146,32 +148,20 @@ class GoogleMapController { /// The returned [Future] completes after listeners have been notified. Future _updateCircles(CircleUpdates circleUpdates) { assert(circleUpdates != null); - return GoogleMapsFlutterPlatform.instance - .updateCircles(circleUpdates, mapId: mapId); + return _googleMapsFlutterPlatform.updateCircles(circleUpdates, + mapId: mapId); } - /// Updates tile overlays configuration. + /// Updates heatmap configuration. /// /// Change listeners are notified once the update has been made on the /// platform side. /// /// The returned [Future] completes after listeners have been notified. - Future _updateTileOverlays(Set newTileOverlays) { - return GoogleMapsFlutterPlatform.instance - .updateTileOverlays(newTileOverlays: newTileOverlays, mapId: mapId); - } - - /// Clears the tile cache so that all tiles will be requested again from the - /// [TileProvider]. - /// - /// The current tiles from this tile overlay will also be - /// cleared from the map after calling this method. The API maintains a small - /// in-memory cache of tiles. If you want to cache tiles for longer, you - /// should implement an on-disk cache. - Future clearTileCache(TileOverlayId tileOverlayId) async { - assert(tileOverlayId != null); - return GoogleMapsFlutterPlatform.instance - .clearTileCache(tileOverlayId, mapId: mapId); + Future _updateHeatmaps(HeatmapUpdates heatmapUpdates) { + assert(heatmapUpdates != null); + return _googleMapsFlutterPlatform.updateHeatmaps(heatmapUpdates, + mapId: mapId); } /// Starts an animated change of the map camera position. @@ -179,8 +169,7 @@ class GoogleMapController { /// The returned [Future] completes after the change has been started on the /// platform side. Future animateCamera(CameraUpdate cameraUpdate) { - return GoogleMapsFlutterPlatform.instance - .animateCamera(cameraUpdate, mapId: mapId); + return _googleMapsFlutterPlatform.animateCamera(cameraUpdate, mapId: mapId); } /// Changes the map camera position. @@ -188,8 +177,7 @@ class GoogleMapController { /// The returned [Future] completes after the change has been made on the /// platform side. Future moveCamera(CameraUpdate cameraUpdate) { - return GoogleMapsFlutterPlatform.instance - .moveCamera(cameraUpdate, mapId: mapId); + return _googleMapsFlutterPlatform.moveCamera(cameraUpdate, mapId: mapId); } /// Sets the styling of the base map. @@ -205,14 +193,13 @@ class GoogleMapController { /// Also, refer [iOS](https://developers.google.com/maps/documentation/ios-sdk/style-reference) /// and [Android](https://developers.google.com/maps/documentation/android-sdk/style-reference) /// style reference for more information regarding the supported styles. - Future setMapStyle(String? mapStyle) { - return GoogleMapsFlutterPlatform.instance - .setMapStyle(mapStyle, mapId: mapId); + Future setMapStyle(String mapStyle) { + return _googleMapsFlutterPlatform.setMapStyle(mapStyle, mapId: mapId); } /// Return [LatLngBounds] defining the region that is visible in a map. Future getVisibleRegion() { - return GoogleMapsFlutterPlatform.instance.getVisibleRegion(mapId: mapId); + return _googleMapsFlutterPlatform.getVisibleRegion(mapId: mapId); } /// Return [ScreenCoordinate] of the [LatLng] in the current map view. @@ -221,8 +208,7 @@ class GoogleMapController { /// Screen location is in screen pixels (not display pixels) with respect to the top left corner /// of the map, not necessarily of the whole screen. Future getScreenCoordinate(LatLng latLng) { - return GoogleMapsFlutterPlatform.instance - .getScreenCoordinate(latLng, mapId: mapId); + return _googleMapsFlutterPlatform.getScreenCoordinate(latLng, mapId: mapId); } /// Returns [LatLng] corresponding to the [ScreenCoordinate] in the current map view. @@ -230,8 +216,7 @@ class GoogleMapController { /// Returned [LatLng] corresponds to a screen location. The screen location is specified in screen /// pixels (not display pixels) relative to the top left of the map, not top left of the whole screen. Future getLatLng(ScreenCoordinate screenCoordinate) { - return GoogleMapsFlutterPlatform.instance - .getLatLng(screenCoordinate, mapId: mapId); + return _googleMapsFlutterPlatform.getLatLng(screenCoordinate, mapId: mapId); } /// Programmatically show the Info Window for a [Marker]. @@ -244,8 +229,8 @@ class GoogleMapController { /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. Future showMarkerInfoWindow(MarkerId markerId) { assert(markerId != null); - return GoogleMapsFlutterPlatform.instance - .showMarkerInfoWindow(markerId, mapId: mapId); + return _googleMapsFlutterPlatform.showMarkerInfoWindow(markerId, + mapId: mapId); } /// Programmatically hide the Info Window for a [Marker]. @@ -258,8 +243,8 @@ class GoogleMapController { /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. Future hideMarkerInfoWindow(MarkerId markerId) { assert(markerId != null); - return GoogleMapsFlutterPlatform.instance - .hideMarkerInfoWindow(markerId, mapId: mapId); + return _googleMapsFlutterPlatform.hideMarkerInfoWindow(markerId, + mapId: mapId); } /// Returns `true` when the [InfoWindow] is showing, `false` otherwise. @@ -272,22 +257,17 @@ class GoogleMapController { /// * [hideMarkerInfoWindow] to hide the Info Window. Future isMarkerInfoWindowShown(MarkerId markerId) { assert(markerId != null); - return GoogleMapsFlutterPlatform.instance - .isMarkerInfoWindowShown(markerId, mapId: mapId); + return _googleMapsFlutterPlatform.isMarkerInfoWindowShown(markerId, + mapId: mapId); } /// Returns the current zoom level of the map Future getZoomLevel() { - return GoogleMapsFlutterPlatform.instance.getZoomLevel(mapId: mapId); + return _googleMapsFlutterPlatform.getZoomLevel(mapId: mapId); } /// Returns the image bytes of the map - Future takeSnapshot() { - return GoogleMapsFlutterPlatform.instance.takeSnapshot(mapId: mapId); - } - - /// Disposes of the platform resources - void dispose() { - GoogleMapsFlutterPlatform.instance.dispose(mapId: mapId); + Future takeSnapshot() { + return _googleMapsFlutterPlatform.takeSnapshot(mapId: mapId); } } diff --git a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart index 26b9d6b83c84..f7e2c9a947d5 100644 --- a/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter/lib/src/google_map.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -10,44 +10,16 @@ part of google_maps_flutter; /// map is created. typedef void MapCreatedCallback(GoogleMapController controller); -// This counter is used to provide a stable "constant" initialization id -// to the buildView function, so the web implementation can use it as a -// cache key. This needs to be provided from the outside, because web -// views seem to re-render much more often that mobile platform views. -int _nextMapCreationId = 0; - -/// Error thrown when an unknown map object ID is provided to a method. -class UnknownMapObjectIdError extends Error { - /// Creates an assertion error with the provided [message]. - UnknownMapObjectIdError(this.objectType, this.objectId, [this.context]); - - /// The name of the map object whose ID is unknown. - final String objectType; - - /// The unknown maps object ID. - final MapsObjectId objectId; - - /// The context where the error occurred. - final String? context; - - String toString() { - if (context != null) { - return 'Unknown $objectType ID "${objectId.value}" in $context'; - } - return 'Unknown $objectType ID "${objectId.value}"'; - } -} - /// A widget which displays a map with data obtained from the Google Maps service. class GoogleMap extends StatefulWidget { /// Creates a widget displaying data from Google Maps services. /// /// [AssertionError] will be thrown if [initialCameraPosition] is null; const GoogleMap({ - Key? key, - required this.initialCameraPosition, + Key key, + @required this.initialCameraPosition, this.onMapCreated, - this.gestureRecognizers = const >{}, + this.gestureRecognizers, this.compassEnabled = true, this.mapToolbarEnabled = true, this.cameraTargetBounds = CameraTargetBounds.unbounded, @@ -57,7 +29,6 @@ class GoogleMap extends StatefulWidget { this.scrollGesturesEnabled = true, this.zoomControlsEnabled = true, this.zoomGesturesEnabled = true, - this.liteModeEnabled = false, this.tiltGesturesEnabled = true, this.myLocationEnabled = false, this.myLocationButtonEnabled = true, @@ -67,12 +38,12 @@ class GoogleMap extends StatefulWidget { this.indoorViewEnabled = false, this.trafficEnabled = false, this.buildingsEnabled = true, - this.markers = const {}, - this.polygons = const {}, - this.polylines = const {}, - this.circles = const {}, + this.markers, + this.polygons, + this.polylines, + this.circles, + this.heatmaps, this.onCameraMoveStarted, - this.tileOverlays = const {}, this.onCameraMove, this.onCameraIdle, this.onTap, @@ -83,7 +54,7 @@ class GoogleMap extends StatefulWidget { /// Callback method for when the map is ready to be used. /// /// Used to receive a [GoogleMapController] for this [GoogleMap]. - final MapCreatedCallback? onMapCreated; + final MapCreatedCallback onMapCreated; /// The initial position of the map's camera. final CameraPosition initialCameraPosition; @@ -120,11 +91,6 @@ class GoogleMap extends StatefulWidget { /// True if the map view should respond to zoom gestures. final bool zoomGesturesEnabled; - /// True if the map view should be in lite mode. Android only. - /// - /// See https://developers.google.com/maps/documentation/android-sdk/lite#overview_of_lite_mode for more details. - final bool liteModeEnabled; - /// True if the map view should respond to tilt gestures. final bool tiltGesturesEnabled; @@ -143,8 +109,8 @@ class GoogleMap extends StatefulWidget { /// Circles to be placed on the map. final Set circles; - /// Tile overlays to be placed on the map. - final Set tileOverlays; + /// Heatmaps to be placed on the map. + final Set heatmaps; /// Called when the camera starts moving. /// @@ -154,24 +120,24 @@ class GoogleMap extends StatefulWidget { /// 2. Programmatically initiated animation. /// 3. Camera motion initiated in response to user gestures on the map. /// For example: pan, tilt, pinch to zoom, or rotate. - final VoidCallback? onCameraMoveStarted; + final VoidCallback onCameraMoveStarted; /// Called repeatedly as the camera continues to move after an /// onCameraMoveStarted call. /// /// This may be called as often as once every frame and should /// not perform expensive operations. - final CameraPositionCallback? onCameraMove; + final CameraPositionCallback onCameraMove; /// Called when camera movement has ended, there are no pending /// animations and the user has stopped interacting with the map. - final VoidCallback? onCameraIdle; + final VoidCallback onCameraIdle; /// Called every time a [GoogleMap] is tapped. - final ArgumentCallback? onTap; + final ArgumentCallback onTap; /// Called every time a [GoogleMap] is long pressed. - final ArgumentCallback? onLongPress; + final ArgumentCallback onLongPress; /// True if a "My Location" layer should be shown on the map. /// @@ -227,7 +193,7 @@ class GoogleMap extends StatefulWidget { /// vertical drags. The map will claim gestures that are recognized by any of the /// recognizers on this list. /// - /// When this set is empty, the map will only handle pointer events for gestures that + /// When this set is empty or null, the map will only handle pointer events for gestures that /// were not claimed by any other gesture recognizer. final Set> gestureRecognizers; @@ -237,8 +203,6 @@ class GoogleMap extends StatefulWidget { } class _GoogleMapState extends State { - final _mapId = _nextMapCreationId++; - final Completer _controller = Completer(); @@ -246,20 +210,24 @@ class _GoogleMapState extends State { Map _polygons = {}; Map _polylines = {}; Map _circles = {}; - late _GoogleMapOptions _googleMapOptions; + Map _heatmaps = {}; + _GoogleMapOptions _googleMapOptions; @override Widget build(BuildContext context) { - return GoogleMapsFlutterPlatform.instance.buildView( - _mapId, + final Map creationParams = { + 'initialCameraPosition': widget.initialCameraPosition?.toMap(), + 'options': _googleMapOptions.toMap(), + 'markersToAdd': serializeMarkerSet(widget.markers), + 'polygonsToAdd': serializePolygonSet(widget.polygons), + 'polylinesToAdd': serializePolylineSet(widget.polylines), + 'circlesToAdd': serializeCircleSet(widget.circles), + 'heatmapsToAdd': serializeHeatmapSet(widget.heatmaps), + }; + return _googleMapsFlutterPlatform.buildView( + creationParams, + widget.gestureRecognizers, onPlatformViewCreated, - initialCameraPosition: widget.initialCameraPosition, - markers: widget.markers, - polygons: widget.polygons, - polylines: widget.polylines, - circles: widget.circles, - gestureRecognizers: widget.gestureRecognizers, - mapOptions: _googleMapOptions.toMap(), ); } @@ -271,13 +239,7 @@ class _GoogleMapState extends State { _polygons = keyByPolygonId(widget.polygons); _polylines = keyByPolylineId(widget.polylines); _circles = keyByCircleId(widget.circles); - } - - @override - void dispose() async { - super.dispose(); - GoogleMapController controller = await _controller.future; - controller.dispose(); + _heatmaps = keyByHeatmapId(widget.heatmaps); } @override @@ -288,7 +250,7 @@ class _GoogleMapState extends State { _updatePolygons(); _updatePolylines(); _updateCircles(); - _updateTileOverlays(); + _updateHeatmaps(); } void _updateOptions() async { @@ -336,10 +298,12 @@ class _GoogleMapState extends State { _circles = keyByCircleId(widget.circles); } - void _updateTileOverlays() async { + void _updateHeatmaps() async { final GoogleMapController controller = await _controller.future; // ignore: unawaited_futures - controller._updateTileOverlays(widget.tileOverlays); + controller._updateHeatmaps( + HeatmapUpdates.from(_heatmaps.values.toSet(), widget.heatmaps)); + _heatmaps = keyByHeatmapId(widget.heatmaps); } Future onPlatformViewCreated(int id) async { @@ -349,124 +313,110 @@ class _GoogleMapState extends State { this, ); _controller.complete(controller); - _updateTileOverlays(); - final MapCreatedCallback? onMapCreated = widget.onMapCreated; - if (onMapCreated != null) { - onMapCreated(controller); + if (widget.onMapCreated != null) { + widget.onMapCreated(controller); } } void onMarkerTap(MarkerId markerId) { assert(markerId != null); - final Marker? marker = _markers[markerId]; - if (marker == null) { - throw UnknownMapObjectIdError('marker', markerId, 'onTap'); - } - final VoidCallback? onTap = marker.onTap; - if (onTap != null) { - onTap(); + if (_markers[markerId]?.onTap != null) { + _markers[markerId].onTap(); } } void onMarkerDragEnd(MarkerId markerId, LatLng position) { assert(markerId != null); - final Marker? marker = _markers[markerId]; - if (marker == null) { - throw UnknownMapObjectIdError('marker', markerId, 'onDragEnd'); - } - final ValueChanged? onDragEnd = marker.onDragEnd; - if (onDragEnd != null) { - onDragEnd(position); + if (_markers[markerId]?.onDragEnd != null) { + _markers[markerId].onDragEnd(position); } } void onPolygonTap(PolygonId polygonId) { assert(polygonId != null); - final Polygon? polygon = _polygons[polygonId]; - if (polygon == null) { - throw UnknownMapObjectIdError('polygon', polygonId, 'onTap'); - } - final VoidCallback? onTap = polygon.onTap; - if (onTap != null) { - onTap(); - } + _polygons[polygonId].onTap(); } void onPolylineTap(PolylineId polylineId) { assert(polylineId != null); - final Polyline? polyline = _polylines[polylineId]; - if (polyline == null) { - throw UnknownMapObjectIdError('polyline', polylineId, 'onTap'); - } - final VoidCallback? onTap = polyline.onTap; - if (onTap != null) { - onTap(); + if (_polylines[polylineId]?.onTap != null) { + _polylines[polylineId].onTap(); } } void onCircleTap(CircleId circleId) { assert(circleId != null); - final Circle? circle = _circles[circleId]; - if (circle == null) { - throw UnknownMapObjectIdError('marker', circleId, 'onTap'); - } - final VoidCallback? onTap = circle.onTap; - if (onTap != null) { - onTap(); - } + _circles[circleId].onTap(); } void onInfoWindowTap(MarkerId markerId) { assert(markerId != null); - final Marker? marker = _markers[markerId]; - if (marker == null) { - throw UnknownMapObjectIdError('marker', markerId, 'InfoWindow onTap'); - } - final VoidCallback? onTap = marker.infoWindow.onTap; - if (onTap != null) { - onTap(); + if (_markers[markerId]?.infoWindow?.onTap != null) { + _markers[markerId].infoWindow.onTap(); } } void onTap(LatLng position) { assert(position != null); - final ArgumentCallback? onTap = widget.onTap; - if (onTap != null) { - onTap(position); + if (widget.onTap != null) { + widget.onTap(position); } } void onLongPress(LatLng position) { assert(position != null); - final ArgumentCallback? onLongPress = widget.onLongPress; - if (onLongPress != null) { - onLongPress(position); + if (widget.onLongPress != null) { + widget.onLongPress(position); } } } /// Configuration options for the GoogleMaps user interface. +/// +/// When used to change configuration, null values will be interpreted as +/// "do not change this configuration option". class _GoogleMapOptions { - _GoogleMapOptions.fromWidget(GoogleMap map) - : compassEnabled = map.compassEnabled, - mapToolbarEnabled = map.mapToolbarEnabled, - cameraTargetBounds = map.cameraTargetBounds, - mapType = map.mapType, - minMaxZoomPreference = map.minMaxZoomPreference, - rotateGesturesEnabled = map.rotateGesturesEnabled, - scrollGesturesEnabled = map.scrollGesturesEnabled, - tiltGesturesEnabled = map.tiltGesturesEnabled, - trackCameraPosition = map.onCameraMove != null, - zoomControlsEnabled = map.zoomControlsEnabled, - zoomGesturesEnabled = map.zoomGesturesEnabled, - liteModeEnabled = map.liteModeEnabled, - myLocationEnabled = map.myLocationEnabled, - myLocationButtonEnabled = map.myLocationButtonEnabled, - padding = map.padding, - indoorViewEnabled = map.indoorViewEnabled, - trafficEnabled = map.trafficEnabled, - buildingsEnabled = map.buildingsEnabled, - assert(!map.liteModeEnabled || Platform.isAndroid); + _GoogleMapOptions({ + this.compassEnabled, + this.mapToolbarEnabled, + this.cameraTargetBounds, + this.mapType, + this.minMaxZoomPreference, + this.rotateGesturesEnabled, + this.scrollGesturesEnabled, + this.tiltGesturesEnabled, + this.trackCameraPosition, + this.zoomControlsEnabled, + this.zoomGesturesEnabled, + this.myLocationEnabled, + this.myLocationButtonEnabled, + this.padding, + this.indoorViewEnabled, + this.trafficEnabled, + this.buildingsEnabled, + }); + + static _GoogleMapOptions fromWidget(GoogleMap map) { + return _GoogleMapOptions( + compassEnabled: map.compassEnabled, + mapToolbarEnabled: map.mapToolbarEnabled, + cameraTargetBounds: map.cameraTargetBounds, + mapType: map.mapType, + minMaxZoomPreference: map.minMaxZoomPreference, + rotateGesturesEnabled: map.rotateGesturesEnabled, + scrollGesturesEnabled: map.scrollGesturesEnabled, + tiltGesturesEnabled: map.tiltGesturesEnabled, + trackCameraPosition: map.onCameraMove != null, + zoomControlsEnabled: map.zoomControlsEnabled, + zoomGesturesEnabled: map.zoomGesturesEnabled, + myLocationEnabled: map.myLocationEnabled, + myLocationButtonEnabled: map.myLocationButtonEnabled, + padding: map.padding, + indoorViewEnabled: map.indoorViewEnabled, + trafficEnabled: map.trafficEnabled, + buildingsEnabled: map.buildingsEnabled, + ); + } final bool compassEnabled; @@ -490,8 +440,6 @@ class _GoogleMapOptions { final bool zoomGesturesEnabled; - final bool liteModeEnabled; - final bool myLocationEnabled; final bool myLocationButtonEnabled; @@ -505,31 +453,37 @@ class _GoogleMapOptions { final bool buildingsEnabled; Map toMap() { - return { - 'compassEnabled': compassEnabled, - 'mapToolbarEnabled': mapToolbarEnabled, - 'cameraTargetBounds': cameraTargetBounds.toJson(), - 'mapType': mapType.index, - 'minMaxZoomPreference': minMaxZoomPreference.toJson(), - 'rotateGesturesEnabled': rotateGesturesEnabled, - 'scrollGesturesEnabled': scrollGesturesEnabled, - 'tiltGesturesEnabled': tiltGesturesEnabled, - 'zoomControlsEnabled': zoomControlsEnabled, - 'zoomGesturesEnabled': zoomGesturesEnabled, - 'liteModeEnabled': liteModeEnabled, - 'trackCameraPosition': trackCameraPosition, - 'myLocationEnabled': myLocationEnabled, - 'myLocationButtonEnabled': myLocationButtonEnabled, - 'padding': [ - padding.top, - padding.left, - padding.bottom, - padding.right, - ], - 'indoorEnabled': indoorViewEnabled, - 'trafficEnabled': trafficEnabled, - 'buildingsEnabled': buildingsEnabled, - }; + final Map optionsMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + optionsMap[fieldName] = value; + } + } + + addIfNonNull('compassEnabled', compassEnabled); + addIfNonNull('mapToolbarEnabled', mapToolbarEnabled); + addIfNonNull('cameraTargetBounds', cameraTargetBounds?.toJson()); + addIfNonNull('mapType', mapType?.index); + addIfNonNull('minMaxZoomPreference', minMaxZoomPreference?.toJson()); + addIfNonNull('rotateGesturesEnabled', rotateGesturesEnabled); + addIfNonNull('scrollGesturesEnabled', scrollGesturesEnabled); + addIfNonNull('tiltGesturesEnabled', tiltGesturesEnabled); + addIfNonNull('zoomControlsEnabled', zoomControlsEnabled); + addIfNonNull('zoomGesturesEnabled', zoomGesturesEnabled); + addIfNonNull('trackCameraPosition', trackCameraPosition); + addIfNonNull('myLocationEnabled', myLocationEnabled); + addIfNonNull('myLocationButtonEnabled', myLocationButtonEnabled); + addIfNonNull('padding', [ + padding?.top, + padding?.left, + padding?.bottom, + padding?.right, + ]); + addIfNonNull('indoorEnabled', indoorViewEnabled); + addIfNonNull('trafficEnabled', trafficEnabled); + addIfNonNull('buildingsEnabled', buildingsEnabled); + return optionsMap; } Map updatesMap(_GoogleMapOptions newOptions) { diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 0d7475857b31..89fd66a0197e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -1,27 +1,13 @@ 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 - -environment: - sdk: '>=2.12.0 <3.0.0' - flutter: ">=2.0.0" - -flutter: - plugin: - platforms: - android: - package: io.flutter.plugins.googlemaps - pluginClass: GoogleMapsPlugin - ios: - pluginClass: FLTGoogleMapsPlugin +homepage: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter +version: 0.5.27 dependencies: flutter: sdk: flutter - flutter_plugin_android_lifecycle: ^2.0.1 - google_maps_flutter_platform_interface: ^2.0.0 + flutter_plugin_android_lifecycle: ^1.0.0 + google_maps_flutter_platform_interface: ^1.0.1 dev_dependencies: flutter_test: @@ -31,7 +17,20 @@ dev_dependencies: # https://github.com/dart-lang/pub/issues/2101 is resolved. flutter_driver: sdk: flutter - test: ^1.16.0 - pedantic: ^1.10.0 - plugin_platform_interface: ^2.0.0 - stream_transform: ^2.0.0 + test: ^1.6.0 + pedantic: ^1.8.0 + +flutter: + plugin: + platforms: + android: + package: io.flutter.plugins.googlemaps + pluginClass: GoogleMapsPlugin + ios: + pluginClass: FLTGoogleMapsPlugin + web: + default_package: google_maps_flutter_web + +environment: + sdk: ">=2.0.0-dev.47.0 <3.0.0" + flutter: ">=1.12.13+hotfix.5 <2.0.0" diff --git a/packages/google_maps_flutter/google_maps_flutter/test/circle_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/circle_updates_test.dart index e0d1180a0abb..3533ceb229e3 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/circle_updates_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/circle_updates_test.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -9,6 +9,20 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'fake_maps_controllers.dart'; +Set _toSet({Circle c1, Circle c2, Circle c3}) { + final Set res = Set.identity(); + if (c1 != null) { + res.add(c1); + } + if (c2 != null) { + res.add(c2); + } + if (c3 != null) { + res.add(c3); + } + return res; +} + Widget _mapWithCircles(Set circles) { return Directionality( textDirection: TextDirection.ltr, @@ -36,10 +50,10 @@ void main() { testWidgets('Initializing a circle', (WidgetTester tester) async { final Circle c1 = Circle(circleId: CircleId("circle_1")); - await tester.pumpWidget(_mapWithCircles({c1})); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c1))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circlesToAdd.length, 1); final Circle initializedCircle = platformGoogleMap.circlesToAdd.first; @@ -52,11 +66,11 @@ void main() { final Circle c1 = Circle(circleId: CircleId("circle_1")); final Circle c2 = Circle(circleId: CircleId("circle_2")); - await tester.pumpWidget(_mapWithCircles({c1})); - await tester.pumpWidget(_mapWithCircles({c1, c2})); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c1))); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c1, c2: c2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circlesToAdd.length, 1); final Circle addedCircle = platformGoogleMap.circlesToAdd.first; @@ -70,11 +84,11 @@ void main() { testWidgets("Removing a circle", (WidgetTester tester) async { final Circle c1 = Circle(circleId: CircleId("circle_1")); - await tester.pumpWidget(_mapWithCircles({c1})); - await tester.pumpWidget(_mapWithCircles({})); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c1))); + await tester.pumpWidget(_mapWithCircles(null)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circleIdsToRemove.length, 1); expect(platformGoogleMap.circleIdsToRemove.first, equals(c1.circleId)); @@ -86,11 +100,11 @@ void main() { final Circle c1 = Circle(circleId: CircleId("circle_1")); final Circle c2 = Circle(circleId: CircleId("circle_1"), radius: 10); - await tester.pumpWidget(_mapWithCircles({c1})); - await tester.pumpWidget(_mapWithCircles({c2})); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c1))); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circlesToChange.length, 1); expect(platformGoogleMap.circlesToChange.first, equals(c2)); @@ -102,11 +116,11 @@ void main() { final Circle c1 = Circle(circleId: CircleId("circle_1")); final Circle c2 = Circle(circleId: CircleId("circle_1"), radius: 10); - await tester.pumpWidget(_mapWithCircles({c1})); - await tester.pumpWidget(_mapWithCircles({c2})); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c1))); + await tester.pumpWidget(_mapWithCircles(_toSet(c1: c2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circlesToChange.length, 1); final Circle update = platformGoogleMap.circlesToChange.first; @@ -117,16 +131,16 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Circle c1 = Circle(circleId: CircleId("circle_1")); Circle c2 = Circle(circleId: CircleId("circle_2")); - final Set prev = {c1, c2}; + final Set prev = _toSet(c1: c1, c2: c2); c1 = Circle(circleId: CircleId("circle_1"), visible: false); c2 = Circle(circleId: CircleId("circle_2"), radius: 10); - final Set cur = {c1, c2}; + final Set cur = _toSet(c1: c1, c2: c2); await tester.pumpWidget(_mapWithCircles(prev)); await tester.pumpWidget(_mapWithCircles(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circlesToChange, cur); expect(platformGoogleMap.circleIdsToRemove.isEmpty, true); @@ -136,18 +150,18 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Circle c2 = Circle(circleId: CircleId("circle_2")); final Circle c3 = Circle(circleId: CircleId("circle_3")); - final Set prev = {c2, c3}; + final Set prev = _toSet(c2: c2, c3: c3); // c1 is added, c2 is updated, c3 is removed. final Circle c1 = Circle(circleId: CircleId("circle_1")); c2 = Circle(circleId: CircleId("circle_2"), radius: 10); - final Set cur = {c1, c2}; + final Set cur = _toSet(c1: c1, c2: c2); await tester.pumpWidget(_mapWithCircles(prev)); await tester.pumpWidget(_mapWithCircles(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circlesToChange.length, 1); expect(platformGoogleMap.circlesToAdd.length, 1); @@ -162,32 +176,32 @@ void main() { final Circle c1 = Circle(circleId: CircleId("circle_1")); final Circle c2 = Circle(circleId: CircleId("circle_2")); Circle c3 = Circle(circleId: CircleId("circle_3")); - final Set prev = {c1, c2, c3}; + final Set prev = _toSet(c1: c1, c2: c2, c3: c3); c3 = Circle(circleId: CircleId("circle_3"), radius: 10); - final Set cur = {c1, c2, c3}; + final Set cur = _toSet(c1: c1, c2: c2, c3: c3); await tester.pumpWidget(_mapWithCircles(prev)); await tester.pumpWidget(_mapWithCircles(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; - expect(platformGoogleMap.circlesToChange, {c3}); + expect(platformGoogleMap.circlesToChange, _toSet(c3: c3)); expect(platformGoogleMap.circleIdsToRemove.isEmpty, true); expect(platformGoogleMap.circlesToAdd.isEmpty, true); }); testWidgets("Update non platform related attr", (WidgetTester tester) async { Circle c1 = Circle(circleId: CircleId("circle_1")); - final Set prev = {c1}; + final Set prev = _toSet(c1: c1); c1 = Circle(circleId: CircleId("circle_1"), onTap: () => print("hello")); - final Set cur = {c1}; + final Set cur = _toSet(c1: c1); await tester.pumpWidget(_mapWithCircles(prev)); await tester.pumpWidget(_mapWithCircles(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.circlesToChange.isEmpty, true); expect(platformGoogleMap.circleIdsToRemove.isEmpty, true); diff --git a/packages/google_maps_flutter/google_maps_flutter/test/fake_maps_controllers.dart b/packages/google_maps_flutter/google_maps_flutter/test/fake_maps_controllers.dart index 37270ea34d29..5d7d65978043 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/fake_maps_controllers.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/fake_maps_controllers.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -9,87 +9,84 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; class FakePlatformGoogleMap { - FakePlatformGoogleMap(int id, Map params) - : cameraPosition = - CameraPosition.fromMap(params['initialCameraPosition']), - channel = MethodChannel( - 'plugins.flutter.io/google_maps_$id', const StandardMethodCodec()) { + FakePlatformGoogleMap(int id, Map params) { + cameraPosition = CameraPosition.fromMap(params['initialCameraPosition']); + channel = MethodChannel( + 'plugins.flutter.io/google_maps_$id', const StandardMethodCodec()); channel.setMockMethodCallHandler(onMethodCall); updateOptions(params['options']); updateMarkers(params); updatePolygons(params); updatePolylines(params); updateCircles(params); - updateTileOverlays(Map.castFrom(params)); + updateHeatmaps(params); } MethodChannel channel; - CameraPosition? cameraPosition; + CameraPosition cameraPosition; - bool? compassEnabled; + bool compassEnabled; - bool? mapToolbarEnabled; + bool mapToolbarEnabled; - CameraTargetBounds? cameraTargetBounds; + CameraTargetBounds cameraTargetBounds; - MapType? mapType; + MapType mapType; - MinMaxZoomPreference? minMaxZoomPreference; + MinMaxZoomPreference minMaxZoomPreference; - bool? rotateGesturesEnabled; + bool rotateGesturesEnabled; - bool? scrollGesturesEnabled; + bool scrollGesturesEnabled; - bool? tiltGesturesEnabled; + bool tiltGesturesEnabled; - bool? zoomGesturesEnabled; + bool zoomGesturesEnabled; - bool? zoomControlsEnabled; + bool zoomControlsEnabled; - bool? liteModeEnabled; + bool trackCameraPosition; - bool? trackCameraPosition; + bool myLocationEnabled; - bool? myLocationEnabled; + bool trafficEnabled; - bool? trafficEnabled; + bool buildingsEnabled; - bool? buildingsEnabled; + bool myLocationButtonEnabled; - bool? myLocationButtonEnabled; + List padding; - List? padding; + Set markerIdsToRemove; - Set markerIdsToRemove = {}; + Set markersToAdd; - Set markersToAdd = {}; + Set markersToChange; - Set markersToChange = {}; + Set polygonIdsToRemove; - Set polygonIdsToRemove = {}; + Set polygonsToAdd; - Set polygonsToAdd = {}; + Set polygonsToChange; - Set polygonsToChange = {}; + Set polylineIdsToRemove; - Set polylineIdsToRemove = {}; + Set polylinesToAdd; - Set polylinesToAdd = {}; + Set polylinesToChange; - Set polylinesToChange = {}; + Set circleIdsToRemove; - Set circleIdsToRemove = {}; + Set circlesToAdd; - Set circlesToAdd = {}; + Set circlesToChange; - Set circlesToChange = {}; + Set heatmapIdsToRemove; - Set tileOverlayIdsToRemove = {}; + Set heatmapsToAdd; - Set tileOverlaysToAdd = {}; - - Set tileOverlaysToChange = {}; + Set heatmapsToChange; Future onMethodCall(MethodCall call) { switch (call.method) { @@ -105,19 +102,18 @@ class FakePlatformGoogleMap { case 'polylines#update': updatePolylines(call.arguments); return Future.sync(() {}); - case 'tileOverlays#update': - updateTileOverlays( - Map.castFrom(call.arguments)); - return Future.sync(() {}); case 'circles#update': updateCircles(call.arguments); return Future.sync(() {}); + case 'heatmaps#update': + updateHeatmaps(call.arguments); + return Future.sync(() {}); default: return Future.sync(() {}); } } - void updateMarkers(Map? markerUpdates) { + void updateMarkers(Map markerUpdates) { if (markerUpdates == null) { return; } @@ -127,21 +123,29 @@ class FakePlatformGoogleMap { markersToChange = _deserializeMarkers(markerUpdates['markersToChange']); } - Set _deserializeMarkerIds(List? markerIds) { + Set _deserializeMarkerIds(List markerIds) { if (markerIds == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } return markerIds.map((dynamic markerId) => MarkerId(markerId)).toSet(); } Set _deserializeMarkers(dynamic markers) { if (markers == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } final List markersData = markers; - final Set result = {}; - for (Map markerData - in markersData.cast>()) { + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + final Set result = Set(); + for (Map markerData in markersData) { final String markerId = markerData['markerId']; final double alpha = markerData['alpha']; final bool draggable = markerData['draggable']; @@ -169,7 +173,7 @@ class FakePlatformGoogleMap { return result; } - void updatePolygons(Map? polygonUpdates) { + void updatePolygons(Map polygonUpdates) { if (polygonUpdates == null) { return; } @@ -179,33 +183,39 @@ class FakePlatformGoogleMap { polygonsToChange = _deserializePolygons(polygonUpdates['polygonsToChange']); } - Set _deserializePolygonIds(List? polygonIds) { + Set _deserializePolygonIds(List polygonIds) { if (polygonIds == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } return polygonIds.map((dynamic polygonId) => PolygonId(polygonId)).toSet(); } Set _deserializePolygons(dynamic polygons) { if (polygons == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } final List polygonsData = polygons; - final Set result = {}; - for (Map polygonData - in polygonsData.cast>()) { + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + final Set result = Set(); + for (Map polygonData in polygonsData) { final String polygonId = polygonData['polygonId']; final bool visible = polygonData['visible']; final bool geodesic = polygonData['geodesic']; final List points = _deserializePoints(polygonData['points']); - final List> holes = _deserializeHoles(polygonData['holes']); result.add(Polygon( polygonId: PolygonId(polygonId), visible: visible, geodesic: geodesic, points: points, - holes: holes, )); } @@ -218,15 +228,7 @@ class FakePlatformGoogleMap { }).toList(); } - List> _deserializeHoles(List holes) { - return holes.map>((dynamic hole) { - return hole.map((dynamic list) { - return LatLng(list[0], list[1]); - }).toList(); - }).toList(); - } - - void updatePolylines(Map? polylineUpdates) { + void updatePolylines(Map polylineUpdates) { if (polylineUpdates == null) { return; } @@ -237,9 +239,12 @@ class FakePlatformGoogleMap { _deserializePolylines(polylineUpdates['polylinesToChange']); } - Set _deserializePolylineIds(List? polylineIds) { + Set _deserializePolylineIds(List polylineIds) { if (polylineIds == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } return polylineIds .map((dynamic polylineId) => PolylineId(polylineId)) @@ -248,12 +253,17 @@ class FakePlatformGoogleMap { Set _deserializePolylines(dynamic polylines) { if (polylines == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } final List polylinesData = polylines; - final Set result = {}; - for (Map polylineData - in polylinesData.cast>()) { + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + final Set result = Set(); + for (Map polylineData in polylinesData) { final String polylineId = polylineData['polylineId']; final bool visible = polylineData['visible']; final bool geodesic = polylineData['geodesic']; @@ -270,7 +280,7 @@ class FakePlatformGoogleMap { return result; } - void updateCircles(Map? circleUpdates) { + void updateCircles(Map circleUpdates) { if (circleUpdates == null) { return; } @@ -280,46 +290,29 @@ class FakePlatformGoogleMap { circlesToChange = _deserializeCircles(circleUpdates['circlesToChange']); } - void updateTileOverlays(Map updateTileOverlayUpdates) { - if (updateTileOverlayUpdates == null) { - return; - } - final List>? tileOverlaysToAddList = - updateTileOverlayUpdates['tileOverlaysToAdd'] != null - ? List.castFrom>( - updateTileOverlayUpdates['tileOverlaysToAdd']) - : null; - final List? tileOverlayIdsToRemoveList = - updateTileOverlayUpdates['tileOverlayIdsToRemove'] != null - ? List.castFrom( - updateTileOverlayUpdates['tileOverlayIdsToRemove']) - : null; - final List>? tileOverlaysToChangeList = - updateTileOverlayUpdates['tileOverlaysToChange'] != null - ? List.castFrom>( - updateTileOverlayUpdates['tileOverlaysToChange']) - : null; - tileOverlaysToAdd = _deserializeTileOverlays(tileOverlaysToAddList); - tileOverlayIdsToRemove = - _deserializeTileOverlayIds(tileOverlayIdsToRemoveList); - tileOverlaysToChange = _deserializeTileOverlays(tileOverlaysToChangeList); - } - - Set _deserializeCircleIds(List? circleIds) { + Set _deserializeCircleIds(List circleIds) { if (circleIds == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } return circleIds.map((dynamic circleId) => CircleId(circleId)).toSet(); } Set _deserializeCircles(dynamic circles) { if (circles == null) { - return {}; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); } final List circlesData = circles; - final Set result = {}; - for (Map circleData - in circlesData.cast>()) { + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + final Set result = Set(); + for (Map circleData in circlesData) { final String circleId = circleData['circleId']; final bool visible = circleData['visible']; final double radius = circleData['radius']; @@ -334,34 +327,57 @@ class FakePlatformGoogleMap { return result; } - Set _deserializeTileOverlayIds(List? tileOverlayIds) { - if (tileOverlayIds == null || tileOverlayIds.isEmpty) { - return {}; + List _deserializeWeightedPoints(List points) { + return points.map((dynamic list) { + return WeightedLatLng( + point: LatLng(list[0][0], list[0][1]), intensity: list[1]); + }).toList(); + } + + void updateHeatmaps(Map heatmapUpdates) { + if (heatmapUpdates == null) { + return; } - return tileOverlayIds - .map((String tileOverlayId) => TileOverlayId(tileOverlayId)) - .toSet(); + heatmapsToAdd = _deserializeHeatmaps(heatmapUpdates['heatmapsToAdd']); + heatmapIdsToRemove = + _deserializeHeatmapIds(heatmapUpdates['heatmapIdsToRemove']); + heatmapsToChange = _deserializeHeatmaps(heatmapUpdates['heatmapsToChange']); } - Set _deserializeTileOverlays( - List>? tileOverlays) { - if (tileOverlays == null || tileOverlays.isEmpty) { - return {}; - } - final Set result = {}; - for (Map tileOverlayData in tileOverlays) { - final String tileOverlayId = tileOverlayData['tileOverlayId']; - final bool fadeIn = tileOverlayData['fadeIn']; - final double transparency = tileOverlayData['transparency']; - final int zIndex = tileOverlayData['zIndex']; - final bool visible = tileOverlayData['visible']; - - result.add(TileOverlay( - tileOverlayId: TileOverlayId(tileOverlayId), - fadeIn: fadeIn, - transparency: transparency, - zIndex: zIndex, + Set _deserializeHeatmapIds(List heatmapIds) { + if (heatmapIds == null) { + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); + } + return heatmapIds.map((dynamic heatmapId) => HeatmapId(heatmapId)).toSet(); + } + + Set _deserializeHeatmaps(dynamic heatmaps) { + if (heatmaps == null) { + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + return Set(); + } + final List heatmapsData = heatmaps; + // TODO(iskakaushik): Remove this when collection literals makes it to stable. + // https://github.com/flutter/flutter/issues/28312 + // ignore: prefer_collection_literals + final Set result = Set(); + for (Map heatmapData in heatmapsData) { + final String heatmapId = heatmapData['heatmapId']; + final bool visible = heatmapData['visible']; + final double opacity = heatmapData['opacity']; + final List points = + _deserializeWeightedPoints(heatmapData['points']); + + result.add(Heatmap( + heatmapId: HeatmapId(heatmapId), visible: visible, + opacity: opacity, + points: points, )); } @@ -407,9 +423,6 @@ class FakePlatformGoogleMap { if (options.containsKey('zoomControlsEnabled')) { zoomControlsEnabled = options['zoomControlsEnabled']; } - if (options.containsKey('liteModeEnabled')) { - liteModeEnabled = options['liteModeEnabled']; - } if (options.containsKey('myLocationEnabled')) { myLocationEnabled = options['myLocationEnabled']; } @@ -429,13 +442,13 @@ class FakePlatformGoogleMap { } class FakePlatformViewsController { - FakePlatformGoogleMap? lastCreatedView; + FakePlatformGoogleMap lastCreatedView; Future fakePlatformViewsMethodHandler(MethodCall call) { switch (call.method) { case 'create': final Map args = call.arguments; - final Map params = _decodeParams(args['params'])!; + final Map params = _decodeParams(args['params']); lastCreatedView = FakePlatformGoogleMap( args['id'], params, @@ -451,7 +464,7 @@ class FakePlatformViewsController { } } -Map? _decodeParams(Uint8List paramsMessage) { +Map _decodeParams(Uint8List paramsMessage) { final ByteBuffer buffer = paramsMessage.buffer; final ByteData messageBytes = buffer.asByteData( paramsMessage.offsetInBytes, diff --git a/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart index 2b754afbd359..3c1eadb8d2a4 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/google_map_test.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -35,7 +35,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.cameraPosition, const CameraPosition(target: LatLng(10.0, 15.0))); @@ -62,7 +62,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.cameraPosition, const CameraPosition(target: LatLng(10.0, 15.0))); @@ -80,7 +80,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.compassEnabled, false); @@ -109,7 +109,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.mapToolbarEnabled, false); @@ -144,7 +144,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect( platformGoogleMap.cameraTargetBounds, @@ -193,7 +193,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.mapType, MapType.hybrid); @@ -222,7 +222,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.minMaxZoomPreference, const MinMaxZoomPreference(1.0, 3.0)); @@ -253,7 +253,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.rotateGesturesEnabled, false); @@ -282,7 +282,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.scrollGesturesEnabled, false); @@ -311,7 +311,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.tiltGesturesEnabled, false); @@ -339,7 +339,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.trackCameraPosition, false); @@ -369,7 +369,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.zoomGesturesEnabled, false); @@ -398,7 +398,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.zoomControlsEnabled, false); @@ -427,7 +427,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.myLocationEnabled, false); @@ -457,7 +457,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.myLocationButtonEnabled, true); @@ -485,7 +485,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.padding, [0, 0, 0, 0]); }); @@ -501,7 +501,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.padding, [0, 0, 0, 0]); @@ -542,7 +542,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.trafficEnabled, false); @@ -571,7 +571,7 @@ void main() { ); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.buildingsEnabled, false); diff --git a/packages/google_maps_flutter/google_maps_flutter/test/heatmap_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/heatmap_updates_test.dart new file mode 100644 index 000000000000..b9f38490054a --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter/test/heatmap_updates_test.dart @@ -0,0 +1,215 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart'; + +import 'fake_maps_controllers.dart'; + +Set _toSet({Heatmap p1, Heatmap p2, Heatmap p3}) { + final Set res = Set.identity(); + if (p1 != null) { + res.add(p1); + } + if (p2 != null) { + res.add(p2); + } + if (p3 != null) { + res.add(p3); + } + return res; +} + +Widget _mapWithHeatmaps(Set heatmaps) { + return Directionality( + textDirection: TextDirection.ltr, + child: GoogleMap( + initialCameraPosition: const CameraPosition(target: LatLng(10.0, 15.0)), + heatmaps: heatmaps, + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakePlatformViewsController fakePlatformViewsController = + FakePlatformViewsController(); + + setUpAll(() { + SystemChannels.platform_views.setMockMethodCallHandler( + fakePlatformViewsController.fakePlatformViewsMethodHandler); + }); + + setUp(() { + fakePlatformViewsController.reset(); + }); + + testWidgets('Initializing a heatmap', (WidgetTester tester) async { + final Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1))); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + expect(platformGoogleMap.heatmapsToAdd.length, 1); + + final Heatmap initializedHeatmap = platformGoogleMap.heatmapsToAdd.first; + expect(initializedHeatmap, equals(p1)); + expect(platformGoogleMap.heatmapIdsToRemove.isEmpty, true); + expect(platformGoogleMap.heatmapsToChange.isEmpty, true); + }); + + testWidgets("Adding a heatmap", (WidgetTester tester) async { + final Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + final Heatmap p2 = Heatmap(heatmapId: HeatmapId("heatmap_2")); + + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1, p2: p2))); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + expect(platformGoogleMap.heatmapsToAdd.length, 1); + + final Heatmap addedHeatmap = platformGoogleMap.heatmapsToAdd.first; + expect(addedHeatmap, equals(p2)); + + expect(platformGoogleMap.heatmapIdsToRemove.isEmpty, true); + + expect(platformGoogleMap.heatmapsToChange.isEmpty, true); + }); + + testWidgets("Removing a heatmap", (WidgetTester tester) async { + final Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithHeatmaps(null)); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + expect(platformGoogleMap.heatmapIdsToRemove.length, 1); + expect(platformGoogleMap.heatmapIdsToRemove.first, equals(p1.heatmapId)); + + expect(platformGoogleMap.heatmapsToChange.isEmpty, true); + expect(platformGoogleMap.heatmapsToAdd.isEmpty, true); + }); + + testWidgets("Updating a heatmap", (WidgetTester tester) async { + final Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + final Heatmap p2 = Heatmap(heatmapId: HeatmapId("heatmap_1"), opacity: 0.5); + + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p2))); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + expect(platformGoogleMap.heatmapsToChange.length, 1); + expect(platformGoogleMap.heatmapsToChange.first, equals(p2)); + + expect(platformGoogleMap.heatmapIdsToRemove.isEmpty, true); + expect(platformGoogleMap.heatmapsToAdd.isEmpty, true); + }); + + testWidgets("Updating a heatmap", (WidgetTester tester) async { + final Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + final Heatmap p2 = + Heatmap(heatmapId: HeatmapId("heatmap_1"), visible: false); + + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p2))); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + expect(platformGoogleMap.heatmapsToChange.length, 1); + + final Heatmap update = platformGoogleMap.heatmapsToChange.first; + expect(update, equals(p2)); + expect(update.visible, false); + }); + + testWidgets("Mutate a heatmap", (WidgetTester tester) async { + final Heatmap p1 = Heatmap( + heatmapId: HeatmapId("heatmap_1"), + points: [ + WeightedLatLng(point: LatLng(0.0, 0.0), intensity: 1) + ], + ); + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1))); + + p1.points.add(WeightedLatLng(point: LatLng(1.0, 1.0), intensity: 1)); + await tester.pumpWidget(_mapWithHeatmaps(_toSet(p1: p1))); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + expect(platformGoogleMap.heatmapsToChange.length, 1); + expect(platformGoogleMap.heatmapsToChange.first, equals(p1)); + + expect(platformGoogleMap.heatmapIdsToRemove.isEmpty, true); + expect(platformGoogleMap.heatmapsToAdd.isEmpty, true); + }); + + testWidgets("Multi Update", (WidgetTester tester) async { + Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + Heatmap p2 = Heatmap(heatmapId: HeatmapId("heatmap_2")); + final Set prev = _toSet(p1: p1, p2: p2); + p1 = Heatmap(heatmapId: HeatmapId("heatmap_1"), visible: false); + p2 = Heatmap(heatmapId: HeatmapId("heatmap_2"), opacity: 0.5); + final Set cur = _toSet(p1: p1, p2: p2); + + await tester.pumpWidget(_mapWithHeatmaps(prev)); + await tester.pumpWidget(_mapWithHeatmaps(cur)); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + + expect(platformGoogleMap.heatmapsToChange, cur); + expect(platformGoogleMap.heatmapIdsToRemove.isEmpty, true); + expect(platformGoogleMap.heatmapsToAdd.isEmpty, true); + }); + + testWidgets("Multi Update", (WidgetTester tester) async { + Heatmap p2 = Heatmap(heatmapId: HeatmapId("heatmap_2")); + final Heatmap p3 = Heatmap(heatmapId: HeatmapId("heatmap_3")); + final Set prev = _toSet(p2: p2, p3: p3); + + // p1 is added, p2 is updated, p3 is removed. + final Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + p2 = Heatmap(heatmapId: HeatmapId("heatmap_2"), opacity: 0.5); + final Set cur = _toSet(p1: p1, p2: p2); + + await tester.pumpWidget(_mapWithHeatmaps(prev)); + await tester.pumpWidget(_mapWithHeatmaps(cur)); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + + expect(platformGoogleMap.heatmapsToChange.length, 1); + expect(platformGoogleMap.heatmapsToAdd.length, 1); + expect(platformGoogleMap.heatmapIdsToRemove.length, 1); + + expect(platformGoogleMap.heatmapsToChange.first, equals(p2)); + expect(platformGoogleMap.heatmapsToAdd.first, equals(p1)); + expect(platformGoogleMap.heatmapIdsToRemove.first, equals(p3.heatmapId)); + }); + + testWidgets("Partial Update", (WidgetTester tester) async { + final Heatmap p1 = Heatmap(heatmapId: HeatmapId("heatmap_1")); + final Heatmap p2 = Heatmap(heatmapId: HeatmapId("heatmap_2")); + Heatmap p3 = Heatmap(heatmapId: HeatmapId("heatmap_3")); + final Set prev = _toSet(p1: p1, p2: p2, p3: p3); + p3 = Heatmap(heatmapId: HeatmapId("heatmap_3"), opacity: 0.5); + final Set cur = _toSet(p1: p1, p2: p2, p3: p3); + + await tester.pumpWidget(_mapWithHeatmaps(prev)); + await tester.pumpWidget(_mapWithHeatmaps(cur)); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + + expect(platformGoogleMap.heatmapsToChange, _toSet(p3: p3)); + expect(platformGoogleMap.heatmapIdsToRemove.isEmpty, true); + expect(platformGoogleMap.heatmapsToAdd.isEmpty, true); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart deleted file mode 100644 index 6e0f5ed3e4f5..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/test/map_creation_test.dart +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; -import 'package:stream_transform/stream_transform.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - late TestGoogleMapsFlutterPlatform platform; - - setUp(() { - // Use a mock platform so we never need to hit the MethodChannel code. - platform = TestGoogleMapsFlutterPlatform(); - GoogleMapsFlutterPlatform.instance = platform; - }); - - testWidgets('_webOnlyMapCreationId increments with each GoogleMap widget', ( - WidgetTester tester, - ) async { - // Inject two map widgets... - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: Column( - children: const [ - GoogleMap( - initialCameraPosition: CameraPosition( - target: LatLng(43.362, -5.849), - ), - ), - GoogleMap( - initialCameraPosition: CameraPosition( - target: LatLng(47.649, -122.350), - ), - ), - ], - ), - ), - ); - - // Verify that each one was created with a different _webOnlyMapCreationId. - expect(platform.createdIds.length, 2); - expect(platform.createdIds[0], 0); - expect(platform.createdIds[1], 1); - }); - - testWidgets('Calls platform.dispose when GoogleMap is disposed of', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(GoogleMap( - initialCameraPosition: CameraPosition( - target: LatLng(43.3608, -5.8702), - ), - )); - - // Now dispose of the map... - await tester.pumpWidget(Container()); - - expect(platform.disposed, true); - }); -} - -// A dummy implementation of the platform interface for tests. -class TestGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { - TestGoogleMapsFlutterPlatform(); - - // The IDs passed to each call to buildView, in call order. - List createdIds = []; - - // Whether `dispose` has been called. - bool disposed = false; - - // Stream controller to inject events for testing. - final StreamController mapEventStreamController = - StreamController.broadcast(); - - @override - Future init(int mapId) async {} - - @override - Future updateMapOptions( - Map optionsUpdate, { - required int mapId, - }) async {} - - @override - Future updateMarkers( - MarkerUpdates markerUpdates, { - required int mapId, - }) async {} - - @override - Future updatePolygons( - PolygonUpdates polygonUpdates, { - required int mapId, - }) async {} - - @override - Future updatePolylines( - PolylineUpdates polylineUpdates, { - required int mapId, - }) async {} - - @override - Future updateCircles( - CircleUpdates circleUpdates, { - required int mapId, - }) async {} - - @override - Future updateTileOverlays({ - required Set newTileOverlays, - required int mapId, - }) async {} - - @override - Future clearTileCache( - TileOverlayId tileOverlayId, { - required int mapId, - }) async {} - - @override - Future animateCamera( - CameraUpdate cameraUpdate, { - required int mapId, - }) async {} - - @override - Future moveCamera( - CameraUpdate cameraUpdate, { - required int mapId, - }) async {} - - @override - Future setMapStyle( - String? mapStyle, { - required int mapId, - }) async {} - - @override - Future getVisibleRegion({ - required int mapId, - }) async { - return LatLngBounds(southwest: LatLng(0, 0), northeast: LatLng(0, 0)); - } - - @override - Future getScreenCoordinate( - LatLng latLng, { - required int mapId, - }) async { - return ScreenCoordinate(x: 0, y: 0); - } - - @override - Future getLatLng( - ScreenCoordinate screenCoordinate, { - required int mapId, - }) async { - return LatLng(0, 0); - } - - @override - Future showMarkerInfoWindow( - MarkerId markerId, { - required int mapId, - }) async {} - - @override - Future hideMarkerInfoWindow( - MarkerId markerId, { - required int mapId, - }) async {} - - @override - Future isMarkerInfoWindowShown( - MarkerId markerId, { - required int mapId, - }) async { - return false; - } - - @override - Future getZoomLevel({ - required int mapId, - }) async { - return 0.0; - } - - @override - Future takeSnapshot({ - required int mapId, - }) async { - return null; - } - - @override - Stream onCameraMoveStarted({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onCameraMove({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onCameraIdle({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onMarkerTap({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onInfoWindowTap({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onMarkerDragEnd({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onPolylineTap({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onPolygonTap({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onCircleTap({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onTap({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - Stream onLongPress({required int mapId}) { - return mapEventStreamController.stream.whereType(); - } - - @override - void dispose({required int mapId}) { - disposed = true; - } - - @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 {}, - }) { - onPlatformViewCreated(0); - createdIds.add(creationId); - return Container(); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter/test/marker_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/marker_updates_test.dart index e295393fe15a..620e1ef4bfea 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/marker_updates_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/marker_updates_test.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -9,6 +9,20 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'fake_maps_controllers.dart'; +Set _toSet({Marker m1, Marker m2, Marker m3}) { + final Set res = Set.identity(); + if (m1 != null) { + res.add(m1); + } + if (m2 != null) { + res.add(m2); + } + if (m3 != null) { + res.add(m3); + } + return res; +} + Widget _mapWithMarkers(Set markers) { return Directionality( textDirection: TextDirection.ltr, @@ -36,10 +50,10 @@ void main() { testWidgets('Initializing a marker', (WidgetTester tester) async { final Marker m1 = Marker(markerId: MarkerId("marker_1")); - await tester.pumpWidget(_mapWithMarkers({m1})); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m1))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markersToAdd.length, 1); final Marker initializedMarker = platformGoogleMap.markersToAdd.first; @@ -52,11 +66,11 @@ void main() { final Marker m1 = Marker(markerId: MarkerId("marker_1")); final Marker m2 = Marker(markerId: MarkerId("marker_2")); - await tester.pumpWidget(_mapWithMarkers({m1})); - await tester.pumpWidget(_mapWithMarkers({m1, m2})); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m1))); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m1, m2: m2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markersToAdd.length, 1); final Marker addedMarker = platformGoogleMap.markersToAdd.first; @@ -70,11 +84,11 @@ void main() { testWidgets("Removing a marker", (WidgetTester tester) async { final Marker m1 = Marker(markerId: MarkerId("marker_1")); - await tester.pumpWidget(_mapWithMarkers({m1})); - await tester.pumpWidget(_mapWithMarkers({})); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m1))); + await tester.pumpWidget(_mapWithMarkers(null)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markerIdsToRemove.length, 1); expect(platformGoogleMap.markerIdsToRemove.first, equals(m1.markerId)); @@ -86,11 +100,11 @@ void main() { final Marker m1 = Marker(markerId: MarkerId("marker_1")); final Marker m2 = Marker(markerId: MarkerId("marker_1"), alpha: 0.5); - await tester.pumpWidget(_mapWithMarkers({m1})); - await tester.pumpWidget(_mapWithMarkers({m2})); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m1))); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markersToChange.length, 1); expect(platformGoogleMap.markersToChange.first, equals(m2)); @@ -105,11 +119,11 @@ void main() { infoWindow: const InfoWindow(snippet: 'changed'), ); - await tester.pumpWidget(_mapWithMarkers({m1})); - await tester.pumpWidget(_mapWithMarkers({m2})); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m1))); + await tester.pumpWidget(_mapWithMarkers(_toSet(m1: m2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markersToChange.length, 1); final Marker update = platformGoogleMap.markersToChange.first; @@ -120,16 +134,16 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Marker m1 = Marker(markerId: MarkerId("marker_1")); Marker m2 = Marker(markerId: MarkerId("marker_2")); - final Set prev = {m1, m2}; + final Set prev = _toSet(m1: m1, m2: m2); m1 = Marker(markerId: MarkerId("marker_1"), visible: false); m2 = Marker(markerId: MarkerId("marker_2"), draggable: true); - final Set cur = {m1, m2}; + final Set cur = _toSet(m1: m1, m2: m2); await tester.pumpWidget(_mapWithMarkers(prev)); await tester.pumpWidget(_mapWithMarkers(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markersToChange, cur); expect(platformGoogleMap.markerIdsToRemove.isEmpty, true); @@ -139,18 +153,18 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Marker m2 = Marker(markerId: MarkerId("marker_2")); final Marker m3 = Marker(markerId: MarkerId("marker_3")); - final Set prev = {m2, m3}; + final Set prev = _toSet(m2: m2, m3: m3); // m1 is added, m2 is updated, m3 is removed. final Marker m1 = Marker(markerId: MarkerId("marker_1")); m2 = Marker(markerId: MarkerId("marker_2"), draggable: true); - final Set cur = {m1, m2}; + final Set cur = _toSet(m1: m1, m2: m2); await tester.pumpWidget(_mapWithMarkers(prev)); await tester.pumpWidget(_mapWithMarkers(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markersToChange.length, 1); expect(platformGoogleMap.markersToAdd.length, 1); @@ -165,35 +179,35 @@ void main() { final Marker m1 = Marker(markerId: MarkerId("marker_1")); final Marker m2 = Marker(markerId: MarkerId("marker_2")); Marker m3 = Marker(markerId: MarkerId("marker_3")); - final Set prev = {m1, m2, m3}; + final Set prev = _toSet(m1: m1, m2: m2, m3: m3); m3 = Marker(markerId: MarkerId("marker_3"), draggable: true); - final Set cur = {m1, m2, m3}; + final Set cur = _toSet(m1: m1, m2: m2, m3: m3); await tester.pumpWidget(_mapWithMarkers(prev)); await tester.pumpWidget(_mapWithMarkers(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; - expect(platformGoogleMap.markersToChange, {m3}); + expect(platformGoogleMap.markersToChange, _toSet(m3: m3)); expect(platformGoogleMap.markerIdsToRemove.isEmpty, true); expect(platformGoogleMap.markersToAdd.isEmpty, true); }); testWidgets("Update non platform related attr", (WidgetTester tester) async { Marker m1 = Marker(markerId: MarkerId("marker_1")); - final Set prev = {m1}; + final Set prev = _toSet(m1: m1); m1 = Marker( markerId: MarkerId("marker_1"), onTap: () => print("hello"), onDragEnd: (LatLng latLng) => print(latLng)); - final Set cur = {m1}; + final Set cur = _toSet(m1: m1); await tester.pumpWidget(_mapWithMarkers(prev)); await tester.pumpWidget(_mapWithMarkers(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.markersToChange.isEmpty, true); expect(platformGoogleMap.markerIdsToRemove.isEmpty, true); diff --git a/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart index 79c63c1c5459..185c996113af 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/polygon_updates_test.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -9,6 +9,20 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'fake_maps_controllers.dart'; +Set _toSet({Polygon p1, Polygon p2, Polygon p3}) { + final Set res = Set.identity(); + if (p1 != null) { + res.add(p1); + } + if (p2 != null) { + res.add(p2); + } + if (p3 != null) { + res.add(p3); + } + return res; +} + Widget _mapWithPolygons(Set polygons) { return Directionality( textDirection: TextDirection.ltr, @@ -19,29 +33,6 @@ Widget _mapWithPolygons(Set polygons) { ); } -List _rectPoints({ - required double size, - LatLng center = const LatLng(0, 0), -}) { - final halfSize = size / 2; - - return [ - LatLng(center.latitude + halfSize, center.longitude + halfSize), - LatLng(center.latitude - halfSize, center.longitude + halfSize), - LatLng(center.latitude - halfSize, center.longitude - halfSize), - LatLng(center.latitude + halfSize, center.longitude - halfSize), - ]; -} - -Polygon _polygonWithPointsAndHole(PolygonId polygonId) { - _rectPoints(size: 1); - return Polygon( - polygonId: polygonId, - points: _rectPoints(size: 1), - holes: [_rectPoints(size: 0.5)], - ); -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -59,10 +50,10 @@ void main() { testWidgets('Initializing a polygon', (WidgetTester tester) async { final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); - await tester.pumpWidget(_mapWithPolygons({p1})); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonsToAdd.length, 1); final Polygon initializedPolygon = platformGoogleMap.polygonsToAdd.first; @@ -75,11 +66,11 @@ void main() { final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); final Polygon p2 = Polygon(polygonId: PolygonId("polygon_2")); - await tester.pumpWidget(_mapWithPolygons({p1})); - await tester.pumpWidget(_mapWithPolygons({p1, p2})); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1, p2: p2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonsToAdd.length, 1); final Polygon addedPolygon = platformGoogleMap.polygonsToAdd.first; @@ -93,11 +84,11 @@ void main() { testWidgets("Removing a polygon", (WidgetTester tester) async { final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); - await tester.pumpWidget(_mapWithPolygons({p1})); - await tester.pumpWidget(_mapWithPolygons({})); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolygons(null)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonIdsToRemove.length, 1); expect(platformGoogleMap.polygonIdsToRemove.first, equals(p1.polygonId)); @@ -110,11 +101,11 @@ void main() { final Polygon p2 = Polygon(polygonId: PolygonId("polygon_1"), geodesic: true); - await tester.pumpWidget(_mapWithPolygons({p1})); - await tester.pumpWidget(_mapWithPolygons({p2})); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonsToChange.length, 1); expect(platformGoogleMap.polygonsToChange.first, equals(p2)); @@ -122,18 +113,35 @@ void main() { expect(platformGoogleMap.polygonsToAdd.isEmpty, true); }); + testWidgets("Updating a polygon", (WidgetTester tester) async { + final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); + final Polygon p2 = + Polygon(polygonId: PolygonId("polygon_1"), geodesic: true); + + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p2))); + + final FakePlatformGoogleMap platformGoogleMap = + fakePlatformViewsController.lastCreatedView; + expect(platformGoogleMap.polygonsToChange.length, 1); + + final Polygon update = platformGoogleMap.polygonsToChange.first; + expect(update, equals(p2)); + expect(update.geodesic, true); + }); + testWidgets("Mutate a polygon", (WidgetTester tester) async { final Polygon p1 = Polygon( polygonId: PolygonId("polygon_1"), points: [const LatLng(0.0, 0.0)], ); - await tester.pumpWidget(_mapWithPolygons({p1})); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1))); p1.points.add(const LatLng(1.0, 1.0)); - await tester.pumpWidget(_mapWithPolygons({p1})); + await tester.pumpWidget(_mapWithPolygons(_toSet(p1: p1))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonsToChange.length, 1); expect(platformGoogleMap.polygonsToChange.first, equals(p1)); @@ -144,16 +152,16 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); Polygon p2 = Polygon(polygonId: PolygonId("polygon_2")); - final Set prev = {p1, p2}; + final Set prev = _toSet(p1: p1, p2: p2); p1 = Polygon(polygonId: PolygonId("polygon_1"), visible: false); p2 = Polygon(polygonId: PolygonId("polygon_2"), geodesic: true); - final Set cur = {p1, p2}; + final Set cur = _toSet(p1: p1, p2: p2); await tester.pumpWidget(_mapWithPolygons(prev)); await tester.pumpWidget(_mapWithPolygons(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonsToChange, cur); expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); @@ -163,18 +171,18 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Polygon p2 = Polygon(polygonId: PolygonId("polygon_2")); final Polygon p3 = Polygon(polygonId: PolygonId("polygon_3")); - final Set prev = {p2, p3}; + final Set prev = _toSet(p2: p2, p3: p3); // p1 is added, p2 is updated, p3 is removed. final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); p2 = Polygon(polygonId: PolygonId("polygon_2"), geodesic: true); - final Set cur = {p1, p2}; + final Set cur = _toSet(p1: p1, p2: p2); await tester.pumpWidget(_mapWithPolygons(prev)); await tester.pumpWidget(_mapWithPolygons(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonsToChange.length, 1); expect(platformGoogleMap.polygonsToAdd.length, 1); @@ -189,215 +197,34 @@ void main() { final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); final Polygon p2 = Polygon(polygonId: PolygonId("polygon_2")); Polygon p3 = Polygon(polygonId: PolygonId("polygon_3")); - final Set prev = {p1, p2, p3}; + final Set prev = _toSet(p1: p1, p2: p2, p3: p3); p3 = Polygon(polygonId: PolygonId("polygon_3"), geodesic: true); - final Set cur = {p1, p2, p3}; + final Set cur = _toSet(p1: p1, p2: p2, p3: p3); await tester.pumpWidget(_mapWithPolygons(prev)); await tester.pumpWidget(_mapWithPolygons(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; - expect(platformGoogleMap.polygonsToChange, {p3}); + expect(platformGoogleMap.polygonsToChange, _toSet(p3: p3)); expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); expect(platformGoogleMap.polygonsToAdd.isEmpty, true); }); testWidgets("Update non platform related attr", (WidgetTester tester) async { Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); - final Set prev = {p1}; + final Set prev = _toSet(p1: p1); p1 = Polygon(polygonId: PolygonId("polygon_1"), onTap: () => print(2 + 2)); - final Set cur = {p1}; + final Set cur = _toSet(p1: p1); await tester.pumpWidget(_mapWithPolygons(prev)); await tester.pumpWidget(_mapWithPolygons(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformGoogleMap.polygonsToChange.isEmpty, true); - expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); - expect(platformGoogleMap.polygonsToAdd.isEmpty, true); - }); - - testWidgets('Initializing a polygon with points and hole', - (WidgetTester tester) async { - final Polygon p1 = _polygonWithPointsAndHole(PolygonId("polygon_1")); - await tester.pumpWidget(_mapWithPolygons({p1})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.polygonsToAdd.length, 1); - - final Polygon initializedPolygon = platformGoogleMap.polygonsToAdd.first; - expect(initializedPolygon, equals(p1)); - expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); - expect(platformGoogleMap.polygonsToChange.isEmpty, true); - }); - - testWidgets("Adding a polygon with points and hole", - (WidgetTester tester) async { - final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); - final Polygon p2 = _polygonWithPointsAndHole(PolygonId("polygon_2")); - - await tester.pumpWidget(_mapWithPolygons({p1})); - await tester.pumpWidget(_mapWithPolygons({p1, p2})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.polygonsToAdd.length, 1); - - final Polygon addedPolygon = platformGoogleMap.polygonsToAdd.first; - expect(addedPolygon, equals(p2)); - - expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polygonsToChange.isEmpty, true); - }); - - testWidgets("Removing a polygon with points and hole", - (WidgetTester tester) async { - final Polygon p1 = _polygonWithPointsAndHole(PolygonId("polygon_1")); - - await tester.pumpWidget(_mapWithPolygons({p1})); - await tester.pumpWidget(_mapWithPolygons({})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.polygonIdsToRemove.length, 1); - expect(platformGoogleMap.polygonIdsToRemove.first, equals(p1.polygonId)); - - expect(platformGoogleMap.polygonsToChange.isEmpty, true); - expect(platformGoogleMap.polygonsToAdd.isEmpty, true); - }); - - testWidgets("Updating a polygon by adding points and hole", - (WidgetTester tester) async { - final Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); - final Polygon p2 = _polygonWithPointsAndHole(PolygonId("polygon_1")); - - await tester.pumpWidget(_mapWithPolygons({p1})); - await tester.pumpWidget(_mapWithPolygons({p2})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.polygonsToChange.length, 1); - expect(platformGoogleMap.polygonsToChange.first, equals(p2)); - - expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); - expect(platformGoogleMap.polygonsToAdd.isEmpty, true); - }); - - testWidgets("Mutate a polygon with points and holes", - (WidgetTester tester) async { - final Polygon p1 = Polygon( - polygonId: PolygonId("polygon_1"), - points: _rectPoints(size: 1), - holes: [_rectPoints(size: 0.5)], - ); - await tester.pumpWidget(_mapWithPolygons({p1})); - - p1.points - ..clear() - ..addAll(_rectPoints(size: 2)); - p1.holes - ..clear() - ..addAll([_rectPoints(size: 1)]); - await tester.pumpWidget(_mapWithPolygons({p1})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.polygonsToChange.length, 1); - expect(platformGoogleMap.polygonsToChange.first, equals(p1)); - - expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); - expect(platformGoogleMap.polygonsToAdd.isEmpty, true); - }); - - testWidgets("Multi Update polygons with points and hole", - (WidgetTester tester) async { - Polygon p1 = Polygon(polygonId: PolygonId("polygon_1")); - Polygon p2 = Polygon( - polygonId: PolygonId("polygon_2"), - points: _rectPoints(size: 2), - holes: [_rectPoints(size: 1)], - ); - final Set prev = {p1, p2}; - p1 = Polygon(polygonId: PolygonId("polygon_1"), visible: false); - p2 = p2.copyWith( - pointsParam: _rectPoints(size: 5), - holesParam: [_rectPoints(size: 2)], - ); - final Set cur = {p1, p2}; - - await tester.pumpWidget(_mapWithPolygons(prev)); - await tester.pumpWidget(_mapWithPolygons(cur)); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformGoogleMap.polygonsToChange, cur); - expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); - expect(platformGoogleMap.polygonsToAdd.isEmpty, true); - }); - - testWidgets("Multi Update polygons with points and hole", - (WidgetTester tester) async { - Polygon p2 = Polygon( - polygonId: PolygonId("polygon_2"), - points: _rectPoints(size: 2), - holes: [_rectPoints(size: 1)], - ); - final Polygon p3 = Polygon(polygonId: PolygonId("polygon_3")); - final Set prev = {p2, p3}; - - // p1 is added, p2 is updated, p3 is removed. - final Polygon p1 = _polygonWithPointsAndHole(PolygonId("polygon_1")); - p2 = p2.copyWith( - pointsParam: _rectPoints(size: 5), - holesParam: [_rectPoints(size: 3)], - ); - final Set cur = {p1, p2}; - - await tester.pumpWidget(_mapWithPolygons(prev)); - await tester.pumpWidget(_mapWithPolygons(cur)); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformGoogleMap.polygonsToChange.length, 1); - expect(platformGoogleMap.polygonsToAdd.length, 1); - expect(platformGoogleMap.polygonIdsToRemove.length, 1); - - expect(platformGoogleMap.polygonsToChange.first, equals(p2)); - expect(platformGoogleMap.polygonsToAdd.first, equals(p1)); - expect(platformGoogleMap.polygonIdsToRemove.first, equals(p3.polygonId)); - }); - - testWidgets("Partial Update polygons with points and hole", - (WidgetTester tester) async { - final Polygon p1 = _polygonWithPointsAndHole(PolygonId("polygon_1")); - final Polygon p2 = Polygon(polygonId: PolygonId("polygon_2")); - Polygon p3 = Polygon( - polygonId: PolygonId("polygon_3"), - points: _rectPoints(size: 2), - holes: [_rectPoints(size: 1)], - ); - final Set prev = {p1, p2, p3}; - p3 = p3.copyWith( - pointsParam: _rectPoints(size: 5), - holesParam: [_rectPoints(size: 3)], - ); - final Set cur = {p1, p2, p3}; - - await tester.pumpWidget(_mapWithPolygons(prev)); - await tester.pumpWidget(_mapWithPolygons(cur)); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformGoogleMap.polygonsToChange, {p3}); expect(platformGoogleMap.polygonIdsToRemove.isEmpty, true); expect(platformGoogleMap.polygonsToAdd.isEmpty, true); }); diff --git a/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart index 01eb2e2ce724..269e8f1313f5 100644 --- a/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/test/polyline_updates_test.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -9,6 +9,20 @@ import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'fake_maps_controllers.dart'; +Set _toSet({Polyline p1, Polyline p2, Polyline p3}) { + final Set res = Set.identity(); + if (p1 != null) { + res.add(p1); + } + if (p2 != null) { + res.add(p2); + } + if (p3 != null) { + res.add(p3); + } + return res; +} + Widget _mapWithPolylines(Set polylines) { return Directionality( textDirection: TextDirection.ltr, @@ -36,10 +50,10 @@ void main() { testWidgets('Initializing a polyline', (WidgetTester tester) async { final Polyline p1 = Polyline(polylineId: PolylineId("polyline_1")); - await tester.pumpWidget(_mapWithPolylines({p1})); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToAdd.length, 1); final Polyline initializedPolyline = platformGoogleMap.polylinesToAdd.first; @@ -52,11 +66,11 @@ void main() { final Polyline p1 = Polyline(polylineId: PolylineId("polyline_1")); final Polyline p2 = Polyline(polylineId: PolylineId("polyline_2")); - await tester.pumpWidget(_mapWithPolylines({p1})); - await tester.pumpWidget(_mapWithPolylines({p1, p2})); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1, p2: p2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToAdd.length, 1); final Polyline addedPolyline = platformGoogleMap.polylinesToAdd.first; @@ -70,11 +84,11 @@ void main() { testWidgets("Removing a polyline", (WidgetTester tester) async { final Polyline p1 = Polyline(polylineId: PolylineId("polyline_1")); - await tester.pumpWidget(_mapWithPolylines({p1})); - await tester.pumpWidget(_mapWithPolylines({})); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolylines(null)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylineIdsToRemove.length, 1); expect(platformGoogleMap.polylineIdsToRemove.first, equals(p1.polylineId)); @@ -87,11 +101,11 @@ void main() { final Polyline p2 = Polyline(polylineId: PolylineId("polyline_1"), geodesic: true); - await tester.pumpWidget(_mapWithPolylines({p1})); - await tester.pumpWidget(_mapWithPolylines({p2})); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToChange.length, 1); expect(platformGoogleMap.polylinesToChange.first, equals(p2)); @@ -104,11 +118,11 @@ void main() { final Polyline p2 = Polyline(polylineId: PolylineId("polyline_1"), geodesic: true); - await tester.pumpWidget(_mapWithPolylines({p1})); - await tester.pumpWidget(_mapWithPolylines({p2})); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1))); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p2))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToChange.length, 1); final Polyline update = platformGoogleMap.polylinesToChange.first; @@ -121,13 +135,13 @@ void main() { polylineId: PolylineId("polyline_1"), points: [const LatLng(0.0, 0.0)], ); - await tester.pumpWidget(_mapWithPolylines({p1})); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1))); p1.points.add(const LatLng(1.0, 1.0)); - await tester.pumpWidget(_mapWithPolylines({p1})); + await tester.pumpWidget(_mapWithPolylines(_toSet(p1: p1))); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToChange.length, 1); expect(platformGoogleMap.polylinesToChange.first, equals(p1)); @@ -138,16 +152,16 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Polyline p1 = Polyline(polylineId: PolylineId("polyline_1")); Polyline p2 = Polyline(polylineId: PolylineId("polyline_2")); - final Set prev = {p1, p2}; + final Set prev = _toSet(p1: p1, p2: p2); p1 = Polyline(polylineId: PolylineId("polyline_1"), visible: false); p2 = Polyline(polylineId: PolylineId("polyline_2"), geodesic: true); - final Set cur = {p1, p2}; + final Set cur = _toSet(p1: p1, p2: p2); await tester.pumpWidget(_mapWithPolylines(prev)); await tester.pumpWidget(_mapWithPolylines(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToChange, cur); expect(platformGoogleMap.polylineIdsToRemove.isEmpty, true); @@ -157,18 +171,18 @@ void main() { testWidgets("Multi Update", (WidgetTester tester) async { Polyline p2 = Polyline(polylineId: PolylineId("polyline_2")); final Polyline p3 = Polyline(polylineId: PolylineId("polyline_3")); - final Set prev = {p2, p3}; + final Set prev = _toSet(p2: p2, p3: p3); // p1 is added, p2 is updated, p3 is removed. final Polyline p1 = Polyline(polylineId: PolylineId("polyline_1")); p2 = Polyline(polylineId: PolylineId("polyline_2"), geodesic: true); - final Set cur = {p1, p2}; + final Set cur = _toSet(p1: p1, p2: p2); await tester.pumpWidget(_mapWithPolylines(prev)); await tester.pumpWidget(_mapWithPolylines(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToChange.length, 1); expect(platformGoogleMap.polylinesToAdd.length, 1); @@ -183,33 +197,37 @@ void main() { final Polyline p1 = Polyline(polylineId: PolylineId("polyline_1")); final Polyline p2 = Polyline(polylineId: PolylineId("polyline_2")); Polyline p3 = Polyline(polylineId: PolylineId("polyline_3")); - final Set prev = {p1, p2, p3}; + final Set prev = _toSet(p1: p1, p2: p2, p3: p3); p3 = Polyline(polylineId: PolylineId("polyline_3"), geodesic: true); - final Set cur = {p1, p2, p3}; + final Set cur = _toSet(p1: p1, p2: p2, p3: p3); await tester.pumpWidget(_mapWithPolylines(prev)); await tester.pumpWidget(_mapWithPolylines(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; - expect(platformGoogleMap.polylinesToChange, {p3}); + expect(platformGoogleMap.polylinesToChange, _toSet(p3: p3)); expect(platformGoogleMap.polylineIdsToRemove.isEmpty, true); expect(platformGoogleMap.polylinesToAdd.isEmpty, true); }); testWidgets("Update non platform related attr", (WidgetTester tester) async { Polyline p1 = Polyline(polylineId: PolylineId("polyline_1"), onTap: null); - final Set prev = {p1}; + final Set prev = _toSet( + p1: p1, + ); p1 = Polyline( polylineId: PolylineId("polyline_1"), onTap: () => print(2 + 2)); - final Set cur = {p1}; + final Set cur = _toSet( + p1: p1, + ); await tester.pumpWidget(_mapWithPolylines(prev)); await tester.pumpWidget(_mapWithPolylines(cur)); final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; + fakePlatformViewsController.lastCreatedView; expect(platformGoogleMap.polylinesToChange.isEmpty, true); expect(platformGoogleMap.polylineIdsToRemove.isEmpty, true); diff --git a/packages/google_maps_flutter/google_maps_flutter/test/tile_overlay_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter/test/tile_overlay_updates_test.dart deleted file mode 100644 index 35732da29726..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter/test/tile_overlay_updates_test.dart +++ /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. - -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; - -import 'fake_maps_controllers.dart'; - -Widget _mapWithTileOverlays(Set tileOverlays) { - return Directionality( - textDirection: TextDirection.ltr, - child: GoogleMap( - initialCameraPosition: const CameraPosition(target: LatLng(10.0, 15.0)), - tileOverlays: tileOverlays, - ), - ); -} - -void main() { - final FakePlatformViewsController fakePlatformViewsController = - FakePlatformViewsController(); - - setUpAll(() { - SystemChannels.platform_views.setMockMethodCallHandler( - fakePlatformViewsController.fakePlatformViewsMethodHandler); - }); - - setUp(() { - fakePlatformViewsController.reset(); - }); - - testWidgets('Initializing a tile overlay', (WidgetTester tester) async { - final TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - await tester.pumpWidget(_mapWithTileOverlays({t1})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.tileOverlaysToAdd.length, 1); - - final TileOverlay initializedTileOverlay = - platformGoogleMap.tileOverlaysToAdd.first; - expect(initializedTileOverlay, equals(t1)); - expect(platformGoogleMap.tileOverlayIdsToRemove.isEmpty, true); - expect(platformGoogleMap.tileOverlaysToChange.isEmpty, true); - }); - - testWidgets("Adding a tile overlay", (WidgetTester tester) async { - final TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - final TileOverlay t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_2")); - - await tester.pumpWidget(_mapWithTileOverlays({t1})); - await tester.pumpWidget(_mapWithTileOverlays({t1, t2})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.tileOverlaysToAdd.length, 1); - - final TileOverlay addedTileOverlay = - platformGoogleMap.tileOverlaysToAdd.first; - expect(addedTileOverlay, equals(t2)); - expect(platformGoogleMap.tileOverlayIdsToRemove.isEmpty, true); - - expect(platformGoogleMap.tileOverlaysToChange.isEmpty, true); - }); - - testWidgets("Removing a tile overlay", (WidgetTester tester) async { - final TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - - await tester.pumpWidget(_mapWithTileOverlays({t1})); - await tester.pumpWidget(_mapWithTileOverlays({})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.tileOverlayIdsToRemove.length, 1); - expect(platformGoogleMap.tileOverlayIdsToRemove.first, - equals(t1.tileOverlayId)); - - expect(platformGoogleMap.tileOverlaysToChange.isEmpty, true); - expect(platformGoogleMap.tileOverlaysToAdd.isEmpty, true); - }); - - testWidgets("Updating a tile overlay", (WidgetTester tester) async { - final TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - final TileOverlay t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1"), zIndex: 10); - - await tester.pumpWidget(_mapWithTileOverlays({t1})); - await tester.pumpWidget(_mapWithTileOverlays({t2})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.tileOverlaysToChange.length, 1); - expect(platformGoogleMap.tileOverlaysToChange.first, equals(t2)); - - expect(platformGoogleMap.tileOverlayIdsToRemove.isEmpty, true); - expect(platformGoogleMap.tileOverlaysToAdd.isEmpty, true); - }); - - testWidgets("Updating a tile overlay", (WidgetTester tester) async { - final TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - final TileOverlay t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1"), zIndex: 10); - - await tester.pumpWidget(_mapWithTileOverlays({t1})); - await tester.pumpWidget(_mapWithTileOverlays({t2})); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - expect(platformGoogleMap.tileOverlaysToChange.length, 1); - - final TileOverlay update = platformGoogleMap.tileOverlaysToChange.first; - expect(update, equals(t2)); - expect(update.zIndex, 10); - }); - - testWidgets("Multi Update", (WidgetTester tester) async { - TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - TileOverlay t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_2")); - final Set prev = {t1, t2}; - t1 = TileOverlay( - tileOverlayId: TileOverlayId("tile_overlay_1"), visible: false); - t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_2"), zIndex: 10); - final Set cur = {t1, t2}; - - await tester.pumpWidget(_mapWithTileOverlays(prev)); - await tester.pumpWidget(_mapWithTileOverlays(cur)); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformGoogleMap.tileOverlaysToChange, cur); - expect(platformGoogleMap.tileOverlayIdsToRemove.isEmpty, true); - expect(platformGoogleMap.tileOverlaysToAdd.isEmpty, true); - }); - - testWidgets("Multi Update", (WidgetTester tester) async { - TileOverlay t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_2")); - final TileOverlay t3 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_3")); - final Set prev = {t2, t3}; - - // t1 is added, t2 is updated, t3 is removed. - final TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_2"), zIndex: 10); - final Set cur = {t1, t2}; - - await tester.pumpWidget(_mapWithTileOverlays(prev)); - await tester.pumpWidget(_mapWithTileOverlays(cur)); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformGoogleMap.tileOverlaysToChange.length, 1); - expect(platformGoogleMap.tileOverlaysToAdd.length, 1); - expect(platformGoogleMap.tileOverlayIdsToRemove.length, 1); - - expect(platformGoogleMap.tileOverlaysToChange.first, equals(t2)); - expect(platformGoogleMap.tileOverlaysToAdd.first, equals(t1)); - expect(platformGoogleMap.tileOverlayIdsToRemove.first, - equals(t3.tileOverlayId)); - }); - - testWidgets("Partial Update", (WidgetTester tester) async { - final TileOverlay t1 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_1")); - final TileOverlay t2 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_2")); - TileOverlay t3 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_3")); - final Set prev = {t1, t2, t3}; - t3 = - TileOverlay(tileOverlayId: TileOverlayId("tile_overlay_3"), zIndex: 10); - final Set cur = {t1, t2, t3}; - - await tester.pumpWidget(_mapWithTileOverlays(prev)); - await tester.pumpWidget(_mapWithTileOverlays(cur)); - - final FakePlatformGoogleMap platformGoogleMap = - fakePlatformViewsController.lastCreatedView!; - - expect(platformGoogleMap.tileOverlaysToChange, {t3}); - expect(platformGoogleMap.tileOverlayIdsToRemove.isEmpty, true); - expect(platformGoogleMap.tileOverlaysToAdd.isEmpty, true); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_platform_interface/CHANGELOG.md index b6603d66fa89..54c2ab5338f1 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,58 +1,3 @@ -## 2.0.4 - -* Preserve the `TileProvider` when copying `TileOverlay`, fixing a - regression with tile overlays introduced in the null safety migration. - -## 2.0.3 - -* Fix type issues in `isMarkerInfoWindowShown` and `getZoomLevel` introduced - in the null safety migration. - -## 2.0.2 - -* Mark constructors for CameraUpdate, CircleId, MapsObjectId, MarkerId, PolygonId, PolylineId and TileOverlayId as const - -## 2.0.1 - -* Update platform_plugin_interface version requirement. - -## 2.0.0 - -* Migrated to null-safety. -* BREAKING CHANGE: Removed deprecated APIs. -* BREAKING CHANGE: Many sets in APIs that used to treat null and empty set as - equivalent now require passing an empty set. -* BREAKING CHANGE: toJson now always returns an `Object`; the details of the - object type and structure should be treated as an implementation detail. - -## 1.2.0 - -* Add TileOverlay support. - -## 1.1.0 - -* Add support for holes in Polygons. - -## 1.0.6 - -* Update Flutter SDK constraint. - -## 1.0.5 - -* Temporarily add a `fromJson` constructor to `BitmapDescriptor` so serialized descriptors can be synchronously re-hydrated. This will be removed when a fix for [this issue](https://github.com/flutter/flutter/issues/70330) lands. - -## 1.0.4 - -* Add a `dispose` method to the interface, so implementations may cleanup resources acquired on `init`. - -## 1.0.3 - -* Pass icon width/height if present on `fromAssetImage` BitmapDescriptors (web only) - -## 1.0.2 - -* Update lower bound of dart dependency to 2.1.0. - ## 1.0.1 * Initial open source release. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/LICENSE b/packages/google_maps_flutter/google_maps_flutter_platform_interface/LICENSE index c6823b81eb84..8940a4be1b58 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/LICENSE +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/LICENSE @@ -1,25 +1,27 @@ -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. +// Copyright 2018 The Chromium 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/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart index 650a839cb676..cb28b40470fd 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/google_maps_flutter_platform_interface.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart index be426483193d..c462b4b182e2 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/events/map_event.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/method_channel/method_channel_google_maps_flutter.dart index 49029cc3d22d..03f18fac0719 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 @@ -1,40 +1,18 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'dart:typed_data'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/gestures.dart'; + import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; import 'package:stream_transform/stream_transform.dart'; -import '../types/tile_overlay_updates.dart'; -import '../types/utils/tile_overlay.dart'; - -/// Error thrown when an unknown map ID is provided to a method channel API. -class UnknownMapIDError extends Error { - /// Creates an assertion error with the provided [mapId] and optional - /// [message]. - UnknownMapIDError(this.mapId, [this.message]); - - /// The unknown ID. - final int mapId; - - /// Message describing the assertion error. - final Object? message; - - String toString() { - if (message != null) { - return "Unknown map ID $mapId: ${Error.safeToString(message)}"; - } - return "Unknown map ID $mapId"; - } -} - /// An implementation of [GoogleMapsFlutterPlatform] that uses [MethodChannel] to communicate with the native code. /// /// The `google_maps_flutter` plugin code itself never talks to the native code directly. It delegates @@ -52,40 +30,24 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { /// Accesses the MethodChannel associated to the passed mapId. MethodChannel channel(int mapId) { - MethodChannel? channel = _channels[mapId]; - if (channel == null) { - throw UnknownMapIDError(mapId); - } - return channel; + return _channels[mapId]; } - // Keep a collection of mapId to a map of TileOverlays. - final Map> _tileOverlays = {}; - - /// Returns the channel for [mapId], creating it if it doesn't already exist. - @visibleForTesting - MethodChannel ensureChannelInitialized(int mapId) { - MethodChannel? channel = _channels[mapId]; - if (channel == null) { + /// Initializes the platform interface with [id]. + /// + /// This method is called when the plugin is first initialized. + @override + Future init(int mapId) { + MethodChannel channel; + if (!_channels.containsKey(mapId)) { channel = MethodChannel('plugins.flutter.io/google_maps_$mapId'); channel.setMethodCallHandler( (MethodCall call) => _handleMethodCall(call, mapId)); _channels[mapId] = channel; } - return channel; - } - - @override - Future init(int mapId) { - MethodChannel channel = ensureChannelInitialized(mapId); return channel.invokeMethod('map#waitForMap'); } - @override - void dispose({required int mapId}) { - // Noop! - } - // The controller we need to broadcast the different events coming // from handleMethodCall. // @@ -99,57 +61,57 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { _mapEventStreamController.stream.where((event) => event.mapId == mapId); @override - Stream onCameraMoveStarted({required int mapId}) { + Stream onCameraMoveStarted({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onCameraMove({required int mapId}) { + Stream onCameraMove({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onCameraIdle({required int mapId}) { + Stream onCameraIdle({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onMarkerTap({required int mapId}) { + Stream onMarkerTap({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onInfoWindowTap({required int mapId}) { + Stream onInfoWindowTap({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onMarkerDragEnd({required int mapId}) { + Stream onMarkerDragEnd({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onPolylineTap({required int mapId}) { + Stream onPolylineTap({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onPolygonTap({required int mapId}) { + Stream onPolygonTap({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onCircleTap({required int mapId}) { + Stream onCircleTap({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onTap({required int mapId}) { + Stream onTap({@required int mapId}) { return _events(mapId).whereType(); } @override - Stream onLongPress({required int mapId}) { + Stream onLongPress({@required int mapId}) { return _events(mapId).whereType(); } @@ -161,7 +123,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { case 'camera#onMove': _mapEventStreamController.add(CameraMoveEvent( mapId, - CameraPosition.fromMap(call.arguments['position'])!, + CameraPosition.fromMap(call.arguments['position']), )); break; case 'camera#onIdle': @@ -176,7 +138,7 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { case 'marker#onDragEnd': _mapEventStreamController.add(MarkerDragEndEvent( mapId, - LatLng.fromJson(call.arguments['position'])!, + LatLng.fromJson(call.arguments['position']), MarkerId(call.arguments['markerId']), )); break; @@ -207,40 +169,30 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { case 'map#onTap': _mapEventStreamController.add(MapTapEvent( mapId, - LatLng.fromJson(call.arguments['position'])!, + LatLng.fromJson(call.arguments['position']), )); break; case 'map#onLongPress': _mapEventStreamController.add(MapLongPressEvent( mapId, - LatLng.fromJson(call.arguments['position'])!, + LatLng.fromJson(call.arguments['position']), )); break; - case 'tileOverlay#getTile': - final Map? tileOverlaysForThisMap = - _tileOverlays[mapId]; - final String tileOverlayId = call.arguments['tileOverlayId']; - final TileOverlay? tileOverlay = - tileOverlaysForThisMap?[TileOverlayId(tileOverlayId)]; - TileProvider? tileProvider = tileOverlay?.tileProvider; - if (tileProvider == null) { - return TileProvider.noTile.toJson(); - } - final Tile tile = await tileProvider.getTile( - call.arguments['x'], - call.arguments['y'], - call.arguments['zoom'], - ); - return tile.toJson(); default: throw MissingPluginException(); } } + /// Updates configuration options of the map user interface. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. @override Future updateMapOptions( Map optionsUpdate, { - required int mapId, + @required int mapId, }) { assert(optionsUpdate != null); return channel(mapId).invokeMethod( @@ -251,10 +203,16 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { ); } + /// Updates marker configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. @override Future updateMarkers( MarkerUpdates markerUpdates, { - required int mapId, + @required int mapId, }) { assert(markerUpdates != null); return channel(mapId).invokeMethod( @@ -263,10 +221,16 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { ); } + /// Updates polygon configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. @override Future updatePolygons( PolygonUpdates polygonUpdates, { - required int mapId, + @required int mapId, }) { assert(polygonUpdates != null); return channel(mapId).invokeMethod( @@ -275,10 +239,16 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { ); } + /// Updates polyline configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. @override Future updatePolylines( PolylineUpdates polylineUpdates, { - required int mapId, + @required int mapId, }) { assert(polylineUpdates != null); return channel(mapId).invokeMethod( @@ -287,10 +257,16 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { ); } + /// Updates circle configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. @override Future updateCircles( CircleUpdates circleUpdates, { - required int mapId, + @required int mapId, }) { assert(circleUpdates != null); return channel(mapId).invokeMethod( @@ -299,170 +275,203 @@ class MethodChannelGoogleMapsFlutter extends GoogleMapsFlutterPlatform { ); } + /// Updates heatmap configuration. + /// + /// Change listeners are notified once the update has been made on the + /// platform side. + /// + /// The returned [Future] completes after listeners have been notified. @override - Future updateTileOverlays({ - required Set newTileOverlays, - required int mapId, + Future updateHeatmaps( + HeatmapUpdates heatmapUpdates, { + @required int mapId, }) { - final Map? currentTileOverlays = - _tileOverlays[mapId]; - Set previousSet = currentTileOverlays != null - ? currentTileOverlays.values.toSet() - : {}; - final TileOverlayUpdates updates = - TileOverlayUpdates.from(previousSet, newTileOverlays); - _tileOverlays[mapId] = keyTileOverlayId(newTileOverlays); + assert(heatmapUpdates != null); return channel(mapId).invokeMethod( - 'tileOverlays#update', - updates.toJson(), + 'heatmaps#update', + heatmapUpdates.toJson(), ); } - @override - Future clearTileCache( - TileOverlayId tileOverlayId, { - required int mapId, - }) { - return channel(mapId) - .invokeMethod('tileOverlays#clearTileCache', { - 'tileOverlayId': tileOverlayId.value, - }); - } - + /// Starts an animated change of the map camera position. + /// + /// The returned [Future] completes after the change has been started on the + /// platform side. @override Future animateCamera( CameraUpdate cameraUpdate, { - required int mapId, + @required int mapId, }) { - return channel(mapId).invokeMethod('camera#animate', { + return channel(mapId) + .invokeMethod('camera#animate', { 'cameraUpdate': cameraUpdate.toJson(), }); } + /// Changes the map camera position. + /// + /// The returned [Future] completes after the change has been made on the + /// platform side. @override Future moveCamera( CameraUpdate cameraUpdate, { - required int mapId, + @required int mapId, }) { return channel(mapId).invokeMethod('camera#move', { 'cameraUpdate': cameraUpdate.toJson(), }); } + /// Sets the styling of the base map. + /// + /// Set to `null` to clear any previous custom styling. + /// + /// If problems were detected with the [mapStyle], including un-parsable + /// styling JSON, unrecognized feature type, unrecognized element type, or + /// invalid styler keys: [MapStyleException] is thrown and the current + /// style is left unchanged. + /// + /// The style string can be generated using [map style tool](https://mapstyle.withgoogle.com/). + /// Also, refer [iOS](https://developers.google.com/maps/documentation/ios-sdk/style-reference) + /// and [Android](https://developers.google.com/maps/documentation/android-sdk/style-reference) + /// style reference for more information regarding the supported styles. @override Future setMapStyle( - String? mapStyle, { - required int mapId, + String mapStyle, { + @required int mapId, }) async { - final List successAndError = (await channel(mapId) - .invokeMethod>('map#setStyle', mapStyle))!; + final List successAndError = await channel(mapId) + .invokeMethod>('map#setStyle', mapStyle); final bool success = successAndError[0]; if (!success) { throw MapStyleException(successAndError[1]); } } + /// Return the region that is visible in a map. @override Future getVisibleRegion({ - required int mapId, + @required int mapId, }) async { - final Map latLngBounds = (await channel(mapId) - .invokeMapMethod('map#getVisibleRegion'))!; - final LatLng southwest = LatLng.fromJson(latLngBounds['southwest'])!; - final LatLng northeast = LatLng.fromJson(latLngBounds['northeast'])!; + final Map latLngBounds = await channel(mapId) + .invokeMapMethod('map#getVisibleRegion'); + final LatLng southwest = LatLng.fromJson(latLngBounds['southwest']); + final LatLng northeast = LatLng.fromJson(latLngBounds['northeast']); return LatLngBounds(northeast: northeast, southwest: southwest); } + /// Return point [Map] of the [screenCoordinateInJson] in the current map view. + /// + /// A projection is used to translate between on screen location and geographic coordinates. + /// Screen location is in screen pixels (not display pixels) with respect to the top left corner + /// of the map, not necessarily of the whole screen. @override Future getScreenCoordinate( LatLng latLng, { - required int mapId, + @required int mapId, }) async { - final Map point = (await channel(mapId) + final Map point = await channel(mapId) .invokeMapMethod( - 'map#getScreenCoordinate', latLng.toJson()))!; + 'map#getScreenCoordinate', latLng.toJson()); - return ScreenCoordinate(x: point['x']!, y: point['y']!); + return ScreenCoordinate(x: point['x'], y: point['y']); } + /// Returns [LatLng] corresponding to the [ScreenCoordinate] in the current map view. + /// + /// Returned [LatLng] corresponds to a screen location. The screen location is specified in screen + /// pixels (not display pixels) relative to the top left of the map, not top left of the whole screen. @override Future getLatLng( ScreenCoordinate screenCoordinate, { - required int mapId, + @required int mapId, }) async { - final List latLng = (await channel(mapId) + final List latLng = await channel(mapId) .invokeMethod>( - 'map#getLatLng', screenCoordinate.toJson()))!; + 'map#getLatLng', screenCoordinate.toJson()); return LatLng(latLng[0], latLng[1]); } + /// Programmatically show the Info Window for a [Marker]. + /// + /// The `markerId` must match one of the markers on the map. + /// An invalid `markerId` triggers an "Invalid markerId" error. + /// + /// * See also: + /// * [hideMarkerInfoWindow] to hide the Info Window. + /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. @override Future showMarkerInfoWindow( MarkerId markerId, { - required int mapId, + @required int mapId, }) { assert(markerId != null); return channel(mapId).invokeMethod( 'markers#showInfoWindow', {'markerId': markerId.value}); } + /// Programmatically hide the Info Window for a [Marker]. + /// + /// The `markerId` must match one of the markers on the map. + /// An invalid `markerId` triggers an "Invalid markerId" error. + /// + /// * See also: + /// * [showMarkerInfoWindow] to show the Info Window. + /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. @override Future hideMarkerInfoWindow( MarkerId markerId, { - required int mapId, + @required int mapId, }) { assert(markerId != null); return channel(mapId).invokeMethod( 'markers#hideInfoWindow', {'markerId': markerId.value}); } + /// Returns `true` when the [InfoWindow] is showing, `false` otherwise. + /// + /// The `markerId` must match one of the markers on the map. + /// An invalid `markerId` triggers an "Invalid markerId" error. + /// + /// * See also: + /// * [showMarkerInfoWindow] to show the Info Window. + /// * [hideMarkerInfoWindow] to hide the Info Window. @override Future isMarkerInfoWindowShown( MarkerId markerId, { - required int mapId, - }) async { + @required int mapId, + }) { assert(markerId != null); - return (await channel(mapId).invokeMethod('markers#isInfoWindowShown', - {'markerId': markerId.value}))!; + return channel(mapId).invokeMethod('markers#isInfoWindowShown', + {'markerId': markerId.value}); } + /// Returns the current zoom level of the map @override Future getZoomLevel({ - required int mapId, - }) async { - return (await channel(mapId).invokeMethod('map#getZoomLevel'))!; + @required int mapId, + }) { + return channel(mapId).invokeMethod('map#getZoomLevel'); } + /// Returns the image bytes of the map @override - Future takeSnapshot({ - required int mapId, + Future takeSnapshot({ + @required int mapId, }) { return channel(mapId).invokeMethod('map#takeSnapshot'); } + /// This method builds the appropriate platform view where the map + /// can be rendered. + /// The `mapId` is passed as a parameter from the framework on the + /// `onPlatformViewCreated` callback. @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(), - 'options': mapOptions, - 'markersToAdd': serializeMarkerSet(markers), - 'polygonsToAdd': serializePolygonSet(polygons), - 'polylinesToAdd': serializePolylineSet(polylines), - 'circlesToAdd': serializeCircleSet(circles), - 'tileOverlaysToAdd': serializeTileOverlaySet(tileOverlays), - }; + Map creationParams, + Set> gestureRecognizers, + PlatformViewCreatedCallback onPlatformViewCreated) { if (defaultTargetPlatform == TargetPlatform.android) { return AndroidView( viewType: 'plugins.flutter.io/google_maps', 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..d60833a1bd15 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 @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -58,7 +58,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// The returned [Future] completes after listeners have been notified. Future updateMapOptions( Map optionsUpdate, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('updateMapOptions() has not been implemented.'); } @@ -71,7 +71,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// The returned [Future] completes after listeners have been notified. Future updateMarkers( MarkerUpdates markerUpdates, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('updateMarkers() has not been implemented.'); } @@ -84,7 +84,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// The returned [Future] completes after listeners have been notified. Future updatePolygons( PolygonUpdates polygonUpdates, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('updatePolygons() has not been implemented.'); } @@ -97,7 +97,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// The returned [Future] completes after listeners have been notified. Future updatePolylines( PolylineUpdates polylineUpdates, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('updatePolylines() has not been implemented.'); } @@ -110,36 +110,22 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// The returned [Future] completes after listeners have been notified. Future updateCircles( CircleUpdates circleUpdates, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('updateCircles() has not been implemented.'); } - /// Updates tile overlay configuration. + /// Updates heatmap configuration. /// /// Change listeners are notified once the update has been made on the /// platform side. /// /// The returned [Future] completes after listeners have been notified. - Future updateTileOverlays({ - required Set newTileOverlays, - required int mapId, + Future updateHeatmaps( + HeatmapUpdates heatmapUpdates, { + @required int mapId, }) { - throw UnimplementedError('updateTileOverlays() has not been implemented.'); - } - - /// Clears the tile cache so that all tiles will be requested again from the - /// [TileProvider]. - /// - /// The current tiles from this tile overlay will also be - /// cleared from the map after calling this method. The Google Maps SDK maintains a small - /// in-memory cache of tiles. If you want to cache tiles for longer, you - /// should implement an on-disk cache. - Future clearTileCache( - TileOverlayId tileOverlayId, { - required int mapId, - }) { - throw UnimplementedError('clearTileCache() has not been implemented.'); + throw UnimplementedError('updateHeatmaps() has not been implemented.'); } /// Starts an animated change of the map camera position. @@ -148,7 +134,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// platform side. Future animateCamera( CameraUpdate cameraUpdate, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('animateCamera() has not been implemented.'); } @@ -159,7 +145,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// platform side. Future moveCamera( CameraUpdate cameraUpdate, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('moveCamera() has not been implemented.'); } @@ -175,15 +161,15 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// /// The style string can be generated using [map style tool](https://mapstyle.withgoogle.com/). Future setMapStyle( - String? mapStyle, { - required int mapId, + String mapStyle, { + @required int mapId, }) { throw UnimplementedError('setMapStyle() has not been implemented.'); } /// Return the region that is visible in a map. Future getVisibleRegion({ - required int mapId, + @required int mapId, }) { throw UnimplementedError('getVisibleRegion() has not been implemented.'); } @@ -195,7 +181,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// of the map, not necessarily of the whole screen. Future getScreenCoordinate( LatLng latLng, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('getScreenCoordinate() has not been implemented.'); } @@ -207,7 +193,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// of the map, not necessarily of the whole screen. Future getLatLng( ScreenCoordinate screenCoordinate, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('getLatLng() has not been implemented.'); } @@ -222,7 +208,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. Future showMarkerInfoWindow( MarkerId markerId, { - required int mapId, + @required int mapId, }) { throw UnimplementedError( 'showMarkerInfoWindow() has not been implemented.'); @@ -238,7 +224,7 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// * [isMarkerInfoWindowShown] to check if the Info Window is showing. Future hideMarkerInfoWindow( MarkerId markerId, { - required int mapId, + @required int mapId, }) { throw UnimplementedError( 'hideMarkerInfoWindow() has not been implemented.'); @@ -254,23 +240,21 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { /// * [hideMarkerInfoWindow] to hide the Info Window. Future isMarkerInfoWindowShown( MarkerId markerId, { - required int mapId, + @required int mapId, }) { throw UnimplementedError('updateMapOptions() has not been implemented.'); } - /// Returns the current zoom level of the map. + /// Returns the current zoom level of the map Future getZoomLevel({ - required int mapId, + @required int mapId, }) { throw UnimplementedError('getZoomLevel() has not been implemented.'); } - /// Returns the image bytes of the map. - /// - /// Returns null if a snapshot cannot be created. - Future takeSnapshot({ - required int mapId, + /// Returns the image bytes of the map + Future takeSnapshot({ + @required int mapId, }) { throw UnimplementedError('takeSnapshot() has not been implemented.'); } @@ -279,81 +263,65 @@ abstract class GoogleMapsFlutterPlatform extends PlatformInterface { // into the plugin /// The Camera started moving. - Stream onCameraMoveStarted({required int mapId}) { + Stream onCameraMoveStarted({@required int mapId}) { throw UnimplementedError('onCameraMoveStarted() has not been implemented.'); } /// The Camera finished moving to a new [CameraPosition]. - Stream onCameraMove({required int mapId}) { + Stream onCameraMove({@required int mapId}) { throw UnimplementedError('onCameraMove() has not been implemented.'); } /// The Camera is now idle. - Stream onCameraIdle({required int mapId}) { + Stream onCameraIdle({@required int mapId}) { throw UnimplementedError('onCameraMove() has not been implemented.'); } /// A [Marker] has been tapped. - Stream onMarkerTap({required int mapId}) { + Stream onMarkerTap({@required int mapId}) { throw UnimplementedError('onMarkerTap() has not been implemented.'); } /// An [InfoWindow] has been tapped. - Stream onInfoWindowTap({required int mapId}) { + Stream onInfoWindowTap({@required int mapId}) { throw UnimplementedError('onInfoWindowTap() has not been implemented.'); } /// A [Marker] has been dragged to a different [LatLng] position. - Stream onMarkerDragEnd({required int mapId}) { + Stream onMarkerDragEnd({@required int mapId}) { throw UnimplementedError('onMarkerDragEnd() has not been implemented.'); } /// A [Polyline] has been tapped. - Stream onPolylineTap({required int mapId}) { + Stream onPolylineTap({@required int mapId}) { throw UnimplementedError('onPolylineTap() has not been implemented.'); } /// A [Polygon] has been tapped. - Stream onPolygonTap({required int mapId}) { + Stream onPolygonTap({@required int mapId}) { throw UnimplementedError('onPolygonTap() has not been implemented.'); } /// A [Circle] has been tapped. - Stream onCircleTap({required int mapId}) { + Stream onCircleTap({@required int mapId}) { throw UnimplementedError('onCircleTap() has not been implemented.'); } /// A Map has been tapped at a certain [LatLng]. - Stream onTap({required int mapId}) { + Stream onTap({@required int mapId}) { throw UnimplementedError('onTap() has not been implemented.'); } /// A Map has been long-pressed at a certain [LatLng]. - Stream onLongPress({required int mapId}) { + Stream onLongPress({@required int mapId}) { throw UnimplementedError('onLongPress() has not been implemented.'); } - /// Dispose of whatever resources the `mapId` is holding on to. - void dispose({required int mapId}) { - throw UnimplementedError('dispose() has not been implemented.'); - } - /// Returns a widget displaying the map view 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 >{}, - // TODO: Replace with a structured type that's part of the interface. - // See https://github.com/flutter/flutter/issues/70330. - Map mapOptions = const {}, - }) { + Map creationParams, + Set> gestureRecognizers, + PlatformViewCreatedCallback onPlatformViewCreated) { throw UnimplementedError('buildView() has not been implemented.'); } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/bitmap.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/bitmap.dart index d3dc37e327fe..40581b43e065 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/bitmap.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/bitmap.dart @@ -1,35 +1,20 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async' show Future; import 'dart:typed_data' show Uint8List; -import 'dart:ui' show Size; import 'package:flutter/material.dart' show ImageConfiguration, AssetImage, AssetBundleImageKey; import 'package:flutter/services.dart' show AssetBundle; -import 'package:flutter/foundation.dart' show kIsWeb; - /// Defines a bitmap image. For a marker, this class can be used to set the /// image of the marker icon. For a ground overlay, it can be used to set the /// image to place on the surface of the earth. class BitmapDescriptor { const BitmapDescriptor._(this._json); - static const String _defaultMarker = 'defaultMarker'; - static const String _fromAsset = 'fromAsset'; - static const String _fromAssetImage = 'fromAssetImage'; - static const String _fromBytes = 'fromBytes'; - - static const Set _validTypes = { - _defaultMarker, - _fromAsset, - _fromAssetImage, - _fromBytes, - }; - /// Convenience hue value representing red. static const double hueRed = 0.0; @@ -62,14 +47,28 @@ class BitmapDescriptor { /// Creates a BitmapDescriptor that refers to the default marker image. static const BitmapDescriptor defaultMarker = - BitmapDescriptor._([_defaultMarker]); + BitmapDescriptor._(['defaultMarker']); /// Creates a BitmapDescriptor that refers to a colorization of the default /// marker image. For convenience, there is a predefined set of hue values. /// See e.g. [hueYellow]. static BitmapDescriptor defaultMarkerWithHue(double hue) { assert(0.0 <= hue && hue < 360.0); - return BitmapDescriptor._([_defaultMarker, hue]); + return BitmapDescriptor._(['defaultMarker', hue]); + } + + /// Creates a BitmapDescriptor using the name of a bitmap image in the assets + /// directory. + /// + /// Use [fromAssetImage]. This method does not respect the screen dpi when + /// picking an asset image. + @Deprecated("Use fromAssetImage instead") + static BitmapDescriptor fromAsset(String assetName, {String package}) { + if (package == null) { + return BitmapDescriptor._(['fromAsset', assetName]); + } else { + return BitmapDescriptor._(['fromAsset', assetName, package]); + } } /// Creates a [BitmapDescriptor] from an asset image. @@ -82,88 +81,36 @@ class BitmapDescriptor { static Future fromAssetImage( ImageConfiguration configuration, String assetName, { - AssetBundle? bundle, - String? package, + AssetBundle bundle, + String package, bool mipmaps = true, }) async { - double? devicePixelRatio = configuration.devicePixelRatio; - if (!mipmaps && devicePixelRatio != null) { - return BitmapDescriptor._([ - _fromAssetImage, + if (!mipmaps && configuration.devicePixelRatio != null) { + return BitmapDescriptor._([ + 'fromAssetImage', assetName, - devicePixelRatio, + configuration.devicePixelRatio, ]); } final AssetImage assetImage = AssetImage(assetName, package: package, bundle: bundle); final AssetBundleImageKey assetBundleImageKey = await assetImage.obtainKey(configuration); - final Size? size = configuration.size; - return BitmapDescriptor._([ - _fromAssetImage, + return BitmapDescriptor._([ + 'fromAssetImage', assetBundleImageKey.name, assetBundleImageKey.scale, - if (kIsWeb && size != null) - [ - size.width, - size.height, - ], ]); } /// Creates a BitmapDescriptor using an array of bytes that must be encoded /// as PNG. static BitmapDescriptor fromBytes(Uint8List byteData) { - return BitmapDescriptor._([_fromBytes, byteData]); - } - - /// The inverse of .toJson. - // This is needed in Web to re-hydrate BitmapDescriptors that have been - // transformed to JSON for transport. - // TODO(https://github.com/flutter/flutter/issues/70330): Clean this up. - BitmapDescriptor.fromJson(Object json) : _json = json { - assert(_json is List); - final jsonList = json as List; - assert(_validTypes.contains(jsonList[0])); - switch (jsonList[0]) { - case _defaultMarker: - assert(jsonList.length <= 2); - if (jsonList.length == 2) { - assert(jsonList[1] is num); - assert(0 <= jsonList[1] && jsonList[1] < 360); - } - break; - case _fromBytes: - assert(jsonList.length == 2); - assert(jsonList[1] != null && jsonList[1] is List); - assert((jsonList[1] as List).isNotEmpty); - break; - case _fromAsset: - assert(jsonList.length <= 3); - assert(jsonList[1] != null && jsonList[1] is String); - assert((jsonList[1] as String).isNotEmpty); - if (jsonList.length == 3) { - assert(jsonList[2] != null && jsonList[2] is String); - assert((jsonList[2] as String).isNotEmpty); - } - break; - case _fromAssetImage: - assert(jsonList.length <= 4); - assert(jsonList[1] != null && jsonList[1] is String); - assert((jsonList[1] as String).isNotEmpty); - assert(jsonList[2] != null && jsonList[2] is double); - if (jsonList.length == 4) { - assert(jsonList[3] != null && jsonList[3] is List); - assert((jsonList[3] as List).length == 2); - } - break; - default: - break; - } + return BitmapDescriptor._(['fromBytes', byteData]); } - final Object _json; + final dynamic _json; /// Convert the object to a Json format. - Object toJson() => _json; + dynamic toJson() => _json; } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/callbacks.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/callbacks.dart index 3b484c1feb05..c20ece5d6c7c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/callbacks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/callbacks.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/camera.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/camera.dart index 7cb6369e7f59..10ea1e98846a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/camera.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/camera.dart @@ -1,9 +1,11 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:ui' show hashValues, Offset; +import 'package:meta/meta.dart' show required; + import 'types.dart'; /// The position of the map "camera", the view point from which the world is shown in the map view. @@ -17,7 +19,7 @@ class CameraPosition { /// null. const CameraPosition({ this.bearing = 0.0, - required this.target, + @required this.target, this.tilt = 0.0, this.zoom = 0.0, }) : assert(bearing != null), @@ -61,7 +63,7 @@ class CameraPosition { /// Serializes [CameraPosition]. /// /// Mainly for internal use when calling [CameraUpdate.newCameraPosition]. - Object toMap() => { + dynamic toMap() => { 'bearing': bearing, 'target': target.toJson(), 'tilt': tilt, @@ -71,27 +73,23 @@ class CameraPosition { /// Deserializes [CameraPosition] from a map. /// /// Mainly for internal use. - static CameraPosition? fromMap(Object? json) { - if (json == null || !(json is Map)) { - return null; - } - final LatLng? target = LatLng.fromJson(json['target']); - if (target == null) { + static CameraPosition fromMap(dynamic json) { + if (json == null) { return null; } return CameraPosition( bearing: json['bearing'], - target: target, + target: LatLng.fromJson(json['target']), tilt: json['tilt'], zoom: json['zoom'], ); } @override - bool operator ==(Object other) { + bool operator ==(dynamic other) { if (identical(this, other)) return true; if (runtimeType != other.runtimeType) return false; - final CameraPosition typedOther = other as CameraPosition; + final CameraPosition typedOther = other; return bearing == typedOther.bearing && target == typedOther.target && tilt == typedOther.tilt && @@ -109,19 +107,19 @@ class CameraPosition { /// Defines a camera move, supporting absolute moves as well as moves relative /// the current position. class CameraUpdate { - const CameraUpdate._(this._json); + CameraUpdate._(this._json); /// Returns a camera update that moves the camera to the specified position. static CameraUpdate newCameraPosition(CameraPosition cameraPosition) { return CameraUpdate._( - ['newCameraPosition', cameraPosition.toMap()], + ['newCameraPosition', cameraPosition.toMap()], ); } /// Returns a camera update that moves the camera target to the specified /// geographical location. static CameraUpdate newLatLng(LatLng latLng) { - return CameraUpdate._(['newLatLng', latLng.toJson()]); + return CameraUpdate._(['newLatLng', latLng.toJson()]); } /// Returns a camera update that transforms the camera so that the specified @@ -129,7 +127,7 @@ class CameraUpdate { /// possible zoom level. A non-zero [padding] insets the bounding box from the /// map view's edges. The camera's new tilt and bearing will both be 0.0. static CameraUpdate newLatLngBounds(LatLngBounds bounds, double padding) { - return CameraUpdate._([ + return CameraUpdate._([ 'newLatLngBounds', bounds.toJson(), padding, @@ -140,7 +138,7 @@ class CameraUpdate { /// geographical location and zoom level. static CameraUpdate newLatLngZoom(LatLng latLng, double zoom) { return CameraUpdate._( - ['newLatLngZoom', latLng.toJson(), zoom], + ['newLatLngZoom', latLng.toJson(), zoom], ); } @@ -152,18 +150,18 @@ class CameraUpdate { /// 75 to the south of the current location, measured in screen coordinates. static CameraUpdate scrollBy(double dx, double dy) { return CameraUpdate._( - ['scrollBy', dx, dy], + ['scrollBy', dx, dy], ); } /// Returns a camera update that modifies the camera zoom level by the /// specified amount. The optional [focus] is a screen point whose underlying /// geographical location should be invariant, if possible, by the movement. - static CameraUpdate zoomBy(double amount, [Offset? focus]) { + static CameraUpdate zoomBy(double amount, [Offset focus]) { if (focus == null) { - return CameraUpdate._(['zoomBy', amount]); + return CameraUpdate._(['zoomBy', amount]); } else { - return CameraUpdate._([ + return CameraUpdate._([ 'zoomBy', amount, [focus.dx, focus.dy], @@ -176,7 +174,7 @@ class CameraUpdate { /// /// Equivalent to the result of calling `zoomBy(1.0)`. static CameraUpdate zoomIn() { - return const CameraUpdate._(['zoomIn']); + return CameraUpdate._(['zoomIn']); } /// Returns a camera update that zooms the camera out, bringing the camera @@ -184,16 +182,16 @@ class CameraUpdate { /// /// Equivalent to the result of calling `zoomBy(-1.0)`. static CameraUpdate zoomOut() { - return const CameraUpdate._(['zoomOut']); + return CameraUpdate._(['zoomOut']); } /// Returns a camera update that sets the camera zoom level. static CameraUpdate zoomTo(double zoom) { - return CameraUpdate._(['zoomTo', zoom]); + return CameraUpdate._(['zoomTo', zoom]); } - final Object _json; + final dynamic _json; /// Converts this object to something serializable in JSON. - Object toJson() => _json; + dynamic toJson() => _json; } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cap.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cap.dart index f5f43209d828..68bf14c36408 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cap.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/cap.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -17,16 +17,16 @@ class Cap { /// /// This is the default cap type at start and end vertices of Polylines with /// solid stroke pattern. - static const Cap buttCap = Cap._(['buttCap']); + static const Cap buttCap = Cap._(['buttCap']); /// Cap that is a semicircle with radius equal to half the stroke width, /// centered at the start or end vertex of a [Polyline] with solid stroke /// pattern. - static const Cap roundCap = Cap._(['roundCap']); + static const Cap roundCap = Cap._(['roundCap']); /// Cap that is squared off after extending half the stroke width beyond the /// start or end vertex of a [Polyline] with solid stroke pattern. - static const Cap squareCap = Cap._(['squareCap']); + static const Cap squareCap = Cap._(['squareCap']); /// Constructs a new CustomCap with a bitmap overlay centered at the start or /// end vertex of a [Polyline], orientated according to the direction of the line's @@ -45,11 +45,11 @@ class Cap { }) { assert(bitmapDescriptor != null); assert(refWidth > 0.0); - return Cap._(['customCap', bitmapDescriptor.toJson(), refWidth]); + return Cap._(['customCap', bitmapDescriptor.toJson(), refWidth]); } - final Object _json; + final dynamic _json; /// Converts this object to something serializable in JSON. - Object toJson() => _json; + dynamic toJson() => _json; } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle.dart index 1845195b31c6..d1418a4c30b1 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle.dart @@ -1,10 +1,10 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/foundation.dart' show VoidCallback; import 'package:flutter/material.dart' show Color, Colors; -import 'package:meta/meta.dart' show immutable; +import 'package:meta/meta.dart' show immutable, required; import 'types.dart'; @@ -12,17 +12,36 @@ import 'types.dart'; /// /// This does not have to be globally unique, only unique among the list. @immutable -class CircleId extends MapsObjectId { +class CircleId { /// Creates an immutable identifier for a [Circle]. - const CircleId(String value) : super(value); + CircleId(this.value) : assert(value != null); + + /// value of the [CircleId]. + final String value; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final CircleId typedOther = other; + return value == typedOther.value; + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'CircleId{value: $value}'; + } } /// Draws a circle on the map. @immutable -class Circle implements MapsObject { +class Circle { /// Creates an immutable representation of a [Circle] to draw on [GoogleMap]. const Circle({ - required this.circleId, + @required this.circleId, this.consumeTapEvents = false, this.fillColor = Colors.transparent, this.center = const LatLng(0.0, 0.0), @@ -37,9 +56,6 @@ class Circle implements MapsObject { /// Uniquely identifies a [Circle]. final CircleId circleId; - @override - CircleId get mapsId => circleId; - /// True if the [Circle] consumes tap events. /// /// If this is false, [onTap] callback will not be triggered. @@ -75,20 +91,20 @@ class Circle implements MapsObject { final int zIndex; /// Callbacks to receive tap events for circle placed on this map. - final VoidCallback? onTap; + final VoidCallback onTap; /// Creates a new [Circle] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Circle copyWith({ - bool? consumeTapEventsParam, - Color? fillColorParam, - LatLng? centerParam, - double? radiusParam, - Color? strokeColorParam, - int? strokeWidthParam, - bool? visibleParam, - int? zIndexParam, - VoidCallback? onTapParam, + bool consumeTapEventsParam, + Color fillColorParam, + LatLng centerParam, + double radiusParam, + Color strokeColorParam, + int strokeWidthParam, + bool visibleParam, + int zIndexParam, + VoidCallback onTapParam, }) { return Circle( circleId: circleId, @@ -108,10 +124,10 @@ class Circle implements MapsObject { Circle clone() => copyWith(); /// Converts this object to something serializable in JSON. - Object toJson() { - final Map json = {}; + dynamic toJson() { + final Map json = {}; - void addIfPresent(String fieldName, Object? value) { + void addIfPresent(String fieldName, dynamic value) { if (value != null) { json[fieldName] = value; } @@ -134,7 +150,7 @@ class Circle implements MapsObject { bool operator ==(Object other) { if (identical(this, other)) return true; if (other.runtimeType != runtimeType) return false; - final Circle typedOther = other as Circle; + final Circle typedOther = other; return circleId == typedOther.circleId && consumeTapEvents == typedOther.consumeTapEvents && fillColor == typedOther.fillColor && diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle_updates.dart index f3fdbb447c94..6f494423a38f 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle_updates.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/circle_updates.dart @@ -1,24 +1,110 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart' show setEquals; + import 'types.dart'; +import 'utils/circle.dart'; /// [Circle] update events to be applied to the [GoogleMap]. /// /// Used in [GoogleMapController] when the map is updated. // (Do not re-export) -class CircleUpdates extends MapsObjectUpdates { +class CircleUpdates { /// Computes [CircleUpdates] given previous and current [Circle]s. - CircleUpdates.from(Set previous, Set current) - : super.from(previous, current, objectName: 'circle'); + CircleUpdates.from(Set previous, Set current) { + if (previous == null) { + previous = Set.identity(); + } + + if (current == null) { + current = Set.identity(); + } + + final Map previousCircles = keyByCircleId(previous); + final Map currentCircles = keyByCircleId(current); + + final Set prevCircleIds = previousCircles.keys.toSet(); + final Set currentCircleIds = currentCircles.keys.toSet(); + + Circle idToCurrentCircle(CircleId id) { + return currentCircles[id]; + } + + final Set _circleIdsToRemove = + prevCircleIds.difference(currentCircleIds); + + final Set _circlesToAdd = currentCircleIds + .difference(prevCircleIds) + .map(idToCurrentCircle) + .toSet(); + + /// Returns `true` if [current] is not equals to previous one with the + /// same id. + bool hasChanged(Circle current) { + final Circle previous = previousCircles[current.circleId]; + return current != previous; + } + + final Set _circlesToChange = currentCircleIds + .intersection(prevCircleIds) + .map(idToCurrentCircle) + .where(hasChanged) + .toSet(); + + circlesToAdd = _circlesToAdd; + circleIdsToRemove = _circleIdsToRemove; + circlesToChange = _circlesToChange; + } /// Set of Circles to be added in this update. - Set get circlesToAdd => objectsToAdd; + Set circlesToAdd; /// Set of CircleIds to be removed in this update. - Set get circleIdsToRemove => objectIdsToRemove.cast(); + Set circleIdsToRemove; /// Set of Circles to be changed in this update. - Set get circlesToChange => objectsToChange; + Set circlesToChange; + + /// Converts this object to something serializable in JSON. + Map toJson() { + final Map updateMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + updateMap[fieldName] = value; + } + } + + addIfNonNull('circlesToAdd', serializeCircleSet(circlesToAdd)); + addIfNonNull('circlesToChange', serializeCircleSet(circlesToChange)); + addIfNonNull('circleIdsToRemove', + circleIdsToRemove.map((CircleId m) => m.value).toList()); + + return updateMap; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final CircleUpdates typedOther = other; + return setEquals(circlesToAdd, typedOther.circlesToAdd) && + setEquals(circleIdsToRemove, typedOther.circleIdsToRemove) && + setEquals(circlesToChange, typedOther.circlesToChange); + } + + @override + int get hashCode => + hashValues(circlesToAdd, circleIdsToRemove, circlesToChange); + + @override + String toString() { + return '_CircleUpdates{circlesToAdd: $circlesToAdd, ' + 'circleIdsToRemove: $circleIdsToRemove, ' + 'circlesToChange: $circlesToChange}'; + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap.dart new file mode 100644 index 000000000000..8d8fbac297b4 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap.dart @@ -0,0 +1,281 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/material.dart' show Color; +import 'package:meta/meta.dart' show immutable, required; + +import 'types.dart'; + +/// Uniquely identifies a [Heatmap] among [GoogleMap] heatmaps. +/// +/// This does not have to be globally unique, only unique among the list. +@immutable +class HeatmapId { + /// Creates an immutable object representing a [HeatmapId] among [GoogleMap] heatmaps. + /// + /// An [AssertionError] will be thrown if [value] is null. + HeatmapId(this.value) : assert(value != null); + + /// value of the [HeatmapId]. + final String value; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final HeatmapId typedOther = other; + return value == typedOther.value; + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'HeatmapId{value: $value}'; + } +} + +/// A pair of latitude and longitude coordinates, stored as degrees with a given intensity. +@immutable +class WeightedLatLng { + /// Creates an immutable object representing a [WeightedLatLng]. + WeightedLatLng({ + @required this.point, + this.intensity = 1, + }); + + /// The location of the [WeightedLatLng]. + final LatLng point; + + /// The intensity of the [WeightedLatLng]. + final int intensity; + + /// Converts this object to something serializable in JSON. + dynamic toJson() { + return [point.toJson(), intensity]; + } + + /// Initialize a HeatmapGradient from an array. + static WeightedLatLng fromJson(dynamic json) { + if (json == null) { + return null; + } + return WeightedLatLng(point: json[0], intensity: json[1]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final WeightedLatLng typedOther = other; + return point == typedOther.point && intensity == typedOther.intensity; + } + + @override + int get hashCode => hashValues(point, intensity); + + @override + String toString() { + return 'WeightedLatLng{point: ${point.toString()}, intensity: $intensity}'; + } +} + +/// An immutable gradient consisting of the given colors. +@immutable +class HeatmapGradient { + /// Creates an immutable object representing a [HeatmapGradient] + HeatmapGradient({ + @required this.colors, + @required this.startPoints, + this.colorMapSize = 256, + }); + + /// The colors to be used in the gradient + final List colors; + + /// The starting point for each color, given as a percentage of the maximum intensity + final List startPoints; + + /// Size of a color map for the heatmap. + final int colorMapSize; + + /// Converts this object to something serializable in JSON. + dynamic toJson() { + return [ + colors.map((Color c) => c.value).toList(), + startPoints, + colorMapSize + ]; + } + + /// Initialize a HeatmapGradient from an array. + static HeatmapGradient fromJson(dynamic json) { + if (json == null) { + return null; + } + return HeatmapGradient( + colors: json[0].map((dynamic value) => Color(value)).toList(), + startPoints: json[1], + colorMapSize: json[2]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final HeatmapGradient typedOther = other; + return listEquals(colors, typedOther.colors) && + listEquals(startPoints, typedOther.startPoints) && + colorMapSize == typedOther.colorMapSize; + } + + @override + int get hashCode => hashValues(colors, startPoints, colorMapSize); + + @override + String toString() { + return 'HeatmapGradient{colors: ${colors.toString()}, startPoints: ${startPoints.toString()}, colorMapSize: ${colorMapSize.toString()}}'; + } +} + +/// Paints a heatmap on geographical locations on the map. +@immutable +class Heatmap { + /// Creates an immutable object representing a heatmap on the map. + const Heatmap({ + @required this.heatmapId, + this.points = const [], + this.gradient, + this.opacity = 0.7, + this.radius = 20, + this.fadeIn = true, + this.transparency = 0, + this.visible = true, + this.zIndex = 0, + }); + + /// Uniquely identifies a [Heatmap]. + final HeatmapId heatmapId; + + /// The vertices of the heatmap to be painted. + final List points; + + /// The gradient of the heatmap points. + final HeatmapGradient gradient; + + /// The opacity of the heatmap points. + final double opacity; + + /// The radius of the points in pixels, between 10 and 50. + final int radius; + + /// Whether the heatmap layer should fade in. + final bool fadeIn; + + /// The transparency of the heatmap layer. + final double transparency; + + /// True if the marker is visible. + final bool visible; + + /// The z-index of the polyline, used to determine relative drawing order of + /// map overlays. + /// + /// Overlays are drawn in order of z-index, so that lower values means drawn + /// earlier, and thus appearing to be closer to the surface of the Earth. + final int zIndex; + + /// Creates a new [Heatmap] object whose values are the same as this instance, + /// unless overwritten by the specified parameters. + Heatmap copyWith({ + List pointsParam, + HeatmapGradient gradientParam, + double opacityParam, + int radiusParam, + bool fadeInParam, + double transparencyParam, + bool visibleParam, + int zIndexParam, + }) { + return Heatmap( + heatmapId: heatmapId, + points: pointsParam ?? points, + gradient: gradientParam ?? gradient, + opacity: opacityParam ?? opacity, + radius: radiusParam ?? radius, + fadeIn: fadeInParam ?? fadeIn, + transparency: transparencyParam ?? transparency, + visible: visibleParam ?? visible, + zIndex: zIndexParam ?? zIndex, + ); + } + + /// Creates a new [Heatmap] object whose values are the same as this + /// instance. + Heatmap clone() { + return copyWith( + pointsParam: List.of(points), + ); + } + + /// Converts this object to something serializable in JSON. + dynamic toJson() { + final Map json = {}; + + void addIfPresent(String fieldName, dynamic value) { + if (value != null) { + json[fieldName] = value; + } + } + + addIfPresent('heatmapId', heatmapId.value); + addIfPresent('opacity', opacity); + addIfPresent('radius', radius); + addIfPresent('fadeIn', fadeIn); + addIfPresent('transparency', transparency); + addIfPresent('visible', visible); + addIfPresent('zIndex', zIndex); + + if (gradient != null) { + json['gradient'] = gradient.toJson(); + } + + if (points != null) { + json['points'] = pointsToJson(); + } + + return json; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final Heatmap typedOther = other; + return heatmapId == typedOther.heatmapId && + listEquals(points, typedOther.points) && + gradient == typedOther.gradient && + opacity == typedOther.opacity && + radius == typedOther.radius && + fadeIn == typedOther.fadeIn && + transparency == typedOther.transparency && + visible == typedOther.visible && + zIndex == typedOther.zIndex; + } + + @override + int get hashCode => heatmapId.hashCode; + + dynamic pointsToJson() { + final List result = []; + for (final WeightedLatLng point in points) { + result.add(point.toJson()); + } + return result; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap_updates.dart new file mode 100644 index 000000000000..c19669c5d01b --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/heatmap_updates.dart @@ -0,0 +1,110 @@ +// Copyright 2018 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart' show setEquals; + +import 'types.dart'; +import 'utils/heatmap.dart'; + +/// [Heatmap] update events to be applied to the [GoogleMap]. +/// +/// Used in [GoogleMapController] when the map is updated. +// (Do not re-export) +class HeatmapUpdates { + /// Computes [HeatmapUpdates] given previous and current [Heatmap]s. + HeatmapUpdates.from(Set previous, Set current) { + if (previous == null) { + previous = Set.identity(); + } + + if (current == null) { + current = Set.identity(); + } + + final Map previousHeatmaps = keyByHeatmapId(previous); + final Map currentHeatmaps = keyByHeatmapId(current); + + final Set prevHeatmapIds = previousHeatmaps.keys.toSet(); + final Set currentHeatmapIds = currentHeatmaps.keys.toSet(); + + Heatmap idToCurrentHeatmap(HeatmapId id) { + return currentHeatmaps[id]; + } + + final Set _heatmapIdsToRemove = + prevHeatmapIds.difference(currentHeatmapIds); + + final Set _heatmapsToAdd = currentHeatmapIds + .difference(prevHeatmapIds) + .map(idToCurrentHeatmap) + .toSet(); + + /// Returns `true` if [current] is not equals to previous one with the + /// same id. + bool hasChanged(Heatmap current) { + final Heatmap previous = previousHeatmaps[current.heatmapId]; + return current != previous; + } + + final Set _heatmapsToChange = currentHeatmapIds + .intersection(prevHeatmapIds) + .map(idToCurrentHeatmap) + .where(hasChanged) + .toSet(); + + heatmapsToAdd = _heatmapsToAdd; + heatmapIdsToRemove = _heatmapIdsToRemove; + heatmapsToChange = _heatmapsToChange; + } + + /// Set of Heatmaps to be added in this update. + Set heatmapsToAdd; + + /// Set of HeatmapIds to be removed in this update. + Set heatmapIdsToRemove; + + /// Set of Heatmaps to be changed in this update. + Set heatmapsToChange; + + /// Converts this object to something serializable in JSON. + Map toJson() { + final Map updateMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + updateMap[fieldName] = value; + } + } + + addIfNonNull('heatmapsToAdd', serializeHeatmapSet(heatmapsToAdd)); + addIfNonNull('heatmapsToChange', serializeHeatmapSet(heatmapsToChange)); + addIfNonNull('heatmapIdsToRemove', + heatmapIdsToRemove.map((HeatmapId m) => m.value).toList()); + + return updateMap; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final HeatmapUpdates typedOther = other; + return setEquals(heatmapsToAdd, typedOther.heatmapsToAdd) && + setEquals(heatmapIdsToRemove, typedOther.heatmapIdsToRemove) && + setEquals(heatmapsToChange, typedOther.heatmapsToChange); + } + + @override + int get hashCode => + hashValues(heatmapsToAdd, heatmapIdsToRemove, heatmapsToChange); + + @override + String toString() { + return 'HeatmapUpdates{heatmapsToAdd: $heatmapsToAdd, ' + 'heatmapIdsToRemove: $heatmapIdsToRemove, ' + 'heatmapsToChange: $heatmapsToChange}'; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/joint_type.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/joint_type.dart index 64e7a3d8cbdc..c7df0b298624 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/joint_type.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/joint_type.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart index 42c66e036fd7..6b76a6d496ac 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/location.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -29,18 +29,16 @@ class LatLng { final double longitude; /// Converts this object to something serializable in JSON. - Object toJson() { + dynamic toJson() { return [latitude, longitude]; } /// Initialize a LatLng from an \[lat, lng\] array. - static LatLng? fromJson(Object? json) { + static LatLng fromJson(dynamic json) { if (json == null) { return null; } - assert(json is List && json.length == 2); - final list = json as List; - return LatLng(list[0], list[1]); + return LatLng(json[0], json[1]); } @override @@ -68,7 +66,7 @@ class LatLngBounds { /// /// The latitude of the southwest corner cannot be larger than the /// latitude of the northeast corner. - LatLngBounds({required this.southwest, required this.northeast}) + LatLngBounds({@required this.southwest, @required this.northeast}) : assert(southwest != null), assert(northeast != null), assert(southwest.latitude <= northeast.latitude); @@ -80,8 +78,8 @@ class LatLngBounds { final LatLng northeast; /// Converts this object to something serializable in JSON. - Object toJson() { - return [southwest.toJson(), northeast.toJson()]; + dynamic toJson() { + return [southwest.toJson(), northeast.toJson()]; } /// Returns whether this rectangle contains the given [LatLng]. @@ -104,15 +102,13 @@ class LatLngBounds { /// Converts a list to [LatLngBounds]. @visibleForTesting - static LatLngBounds? fromList(Object? json) { + static LatLngBounds fromList(dynamic json) { if (json == null) { return null; } - assert(json is List && json.length == 2); - final list = json as List; return LatLngBounds( - southwest: LatLng.fromJson(list[0])!, - northeast: LatLng.fromJson(list[1])!, + southwest: LatLng.fromJson(json[0]), + northeast: LatLng.fromJson(json[1]), ); } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/maps_object.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/maps_object.dart deleted file mode 100644 index 77d958be01e2..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/maps_object.dart +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/foundation.dart' show objectRuntimeType; -import 'package:meta/meta.dart' show immutable; - -/// Uniquely identifies object an among [GoogleMap] collections of a specific -/// type. -/// -/// This does not have to be globally unique, only unique among the collection. -@immutable -class MapsObjectId { - /// Creates an immutable object representing a [T] among [GoogleMap] Ts. - /// - /// An [AssertionError] will be thrown if [value] is null. - const MapsObjectId(this.value) : assert(value != null); - - /// The value of the id. - final String value; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other.runtimeType != runtimeType) return false; - final MapsObjectId typedOther = other as MapsObjectId; - return value == typedOther.value; - } - - @override - int get hashCode => value.hashCode; - - @override - String toString() { - return '${objectRuntimeType(this, 'MapsObjectId')}($value)'; - } -} - -/// A common interface for maps types. -abstract class MapsObject { - /// A identifier for this object. - MapsObjectId get mapsId; - - /// Returns a duplicate of this object. - T clone(); - - /// Converts this object to something serializable in JSON. - Object toJson(); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/maps_object_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/maps_object_updates.dart deleted file mode 100644 index 2e2eefa3d32e..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/maps_object_updates.dart +++ /dev/null @@ -1,126 +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:ui' show hashValues, hashList; - -import 'package:flutter/foundation.dart' show objectRuntimeType, setEquals; - -import 'maps_object.dart'; -import 'utils/maps_object.dart'; - -/// Update specification for a set of objects. -class MapsObjectUpdates { - /// Computes updates given previous and current object sets. - /// - /// [objectName] is the prefix to use when serializing the updates into a JSON - /// dictionary. E.g., 'circle' will give 'circlesToAdd', 'circlesToUpdate', - /// 'circleIdsToRemove'. - MapsObjectUpdates.from( - Set previous, - Set current, { - required this.objectName, - }) { - final Map, T> previousObjects = keyByMapsObjectId(previous); - final Map, T> currentObjects = keyByMapsObjectId(current); - - final Set> previousObjectIds = previousObjects.keys.toSet(); - final Set> currentObjectIds = currentObjects.keys.toSet(); - - /// Maps an ID back to a [T] in [currentObjects]. - /// - /// It is a programming error to call this with an ID that is not guaranteed - /// to be in [currentObjects]. - T _idToCurrentObject(MapsObjectId id) { - return currentObjects[id]!; - } - - _objectIdsToRemove = previousObjectIds.difference(currentObjectIds); - - _objectsToAdd = currentObjectIds - .difference(previousObjectIds) - .map(_idToCurrentObject) - .toSet(); - - // Returns `true` if [current] is not equals to previous one with the - // same id. - bool hasChanged(T current) { - final T? previous = previousObjects[current.mapsId as MapsObjectId]; - return current != previous; - } - - _objectsToChange = currentObjectIds - .intersection(previousObjectIds) - .map(_idToCurrentObject) - .where(hasChanged) - .toSet(); - } - - /// The name of the objects being updated, for use in serialization. - final String objectName; - - /// Set of objects to be added in this update. - Set get objectsToAdd { - return _objectsToAdd; - } - - late Set _objectsToAdd; - - /// Set of objects to be removed in this update. - Set> get objectIdsToRemove { - return _objectIdsToRemove; - } - - late Set> _objectIdsToRemove; - - /// Set of objects to be changed in this update. - Set get objectsToChange { - return _objectsToChange; - } - - late Set _objectsToChange; - - /// Converts this object to JSON. - Object toJson() { - final Map updateMap = {}; - - void addIfNonNull(String fieldName, Object? value) { - if (value != null) { - updateMap[fieldName] = value; - } - } - - addIfNonNull('${objectName}sToAdd', serializeMapsObjectSet(_objectsToAdd)); - addIfNonNull( - '${objectName}sToChange', serializeMapsObjectSet(_objectsToChange)); - addIfNonNull( - '${objectName}IdsToRemove', - _objectIdsToRemove - .map((MapsObjectId m) => m.value) - .toList()); - - return updateMap; - } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - return other is MapsObjectUpdates && - setEquals(_objectsToAdd, other._objectsToAdd) && - setEquals(_objectIdsToRemove, other._objectIdsToRemove) && - setEquals(_objectsToChange, other._objectsToChange); - } - - @override - int get hashCode => hashValues(hashList(_objectsToAdd), - hashList(_objectIdsToRemove), hashList(_objectsToChange)); - - @override - String toString() { - return '${objectRuntimeType(this, 'MapsObjectUpdates')}(add: $objectsToAdd, ' - 'remove: $objectIdsToRemove, ' - 'change: $objectsToChange)'; - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart index 0d1b780c24d2..9b57f9676334 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker.dart @@ -1,16 +1,19 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:ui' show hashValues, Offset; import 'package:flutter/foundation.dart' show ValueChanged, VoidCallback; -import 'package:meta/meta.dart' show immutable; +import 'package:meta/meta.dart' show immutable, required; import 'types.dart'; -Object _offsetToJson(Offset offset) { - return [offset.dx, offset.dy]; +dynamic _offsetToJson(Offset offset) { + if (offset == null) { + return null; + } + return [offset.dx, offset.dy]; } /// Text labels for a [Marker] info window. @@ -29,12 +32,12 @@ class InfoWindow { /// Text displayed in an info window when the user taps the marker. /// /// A null value means no title. - final String? title; + final String title; /// Additional text displayed below the [title]. /// /// A null value means no additional text. - final String? snippet; + final String snippet; /// The icon image point that will be the anchor of the info window when /// displayed. @@ -45,15 +48,15 @@ class InfoWindow { final Offset anchor; /// onTap callback for this [InfoWindow]. - final VoidCallback? onTap; + final VoidCallback onTap; /// Creates a new [InfoWindow] object whose values are the same as this instance, /// unless overwritten by the specified parameters. InfoWindow copyWith({ - String? titleParam, - String? snippetParam, - Offset? anchorParam, - VoidCallback? onTapParam, + String titleParam, + String snippetParam, + Offset anchorParam, + VoidCallback onTapParam, }) { return InfoWindow( title: titleParam ?? title, @@ -63,10 +66,10 @@ class InfoWindow { ); } - Object _toJson() { - final Map json = {}; + dynamic _toJson() { + final Map json = {}; - void addIfPresent(String fieldName, Object? value) { + void addIfPresent(String fieldName, dynamic value) { if (value != null) { json[fieldName] = value; } @@ -83,7 +86,7 @@ class InfoWindow { bool operator ==(Object other) { if (identical(this, other)) return true; if (other.runtimeType != runtimeType) return false; - final InfoWindow typedOther = other as InfoWindow; + final InfoWindow typedOther = other; return title == typedOther.title && snippet == typedOther.snippet && anchor == typedOther.anchor; @@ -102,9 +105,28 @@ class InfoWindow { /// /// This does not have to be globally unique, only unique among the list. @immutable -class MarkerId extends MapsObjectId { +class MarkerId { /// Creates an immutable identifier for a [Marker]. - const MarkerId(String value) : super(value); + MarkerId(this.value) : assert(value != null); + + /// value of the [MarkerId]. + final String value; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final MarkerId typedOther = other; + return value == typedOther.value; + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'MarkerId{value: $value}'; + } } /// Marks a geographical location on the map. @@ -113,7 +135,7 @@ class MarkerId extends MapsObjectId { /// the map's surface; that is, it will not necessarily change orientation /// due to map rotations, tilting, or zooming. @immutable -class Marker implements MapsObject { +class Marker { /// Creates a set of marker configuration options. /// /// Default marker options. @@ -134,7 +156,7 @@ class Marker implements MapsObject { /// * reports [onTap] events /// * reports [onDragEnd] events const Marker({ - required this.markerId, + @required this.markerId, this.alpha = 1.0, this.anchor = const Offset(0.5, 1.0), this.consumeTapEvents = false, @@ -153,9 +175,6 @@ class Marker implements MapsObject { /// Uniquely identifies a [Marker]. final MarkerId markerId; - @override - MarkerId get mapsId => markerId; - /// The opacity of the marker, between 0.0 and 1.0 inclusive. /// /// 0.0 means fully transparent, 1.0 means fully opaque. @@ -205,27 +224,27 @@ class Marker implements MapsObject { final double zIndex; /// Callbacks to receive tap events for markers placed on this map. - final VoidCallback? onTap; + final VoidCallback onTap; /// Signature reporting the new [LatLng] at the end of a drag event. - final ValueChanged? onDragEnd; + final ValueChanged onDragEnd; /// Creates a new [Marker] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Marker copyWith({ - double? alphaParam, - Offset? anchorParam, - bool? consumeTapEventsParam, - bool? draggableParam, - bool? flatParam, - BitmapDescriptor? iconParam, - InfoWindow? infoWindowParam, - LatLng? positionParam, - double? rotationParam, - bool? visibleParam, - double? zIndexParam, - VoidCallback? onTapParam, - ValueChanged? onDragEndParam, + double alphaParam, + Offset anchorParam, + bool consumeTapEventsParam, + bool draggableParam, + bool flatParam, + BitmapDescriptor iconParam, + InfoWindow infoWindowParam, + LatLng positionParam, + double rotationParam, + bool visibleParam, + double zIndexParam, + VoidCallback onTapParam, + ValueChanged onDragEndParam, }) { return Marker( markerId: markerId, @@ -249,10 +268,10 @@ class Marker implements MapsObject { Marker clone() => copyWith(); /// Converts this object to something serializable in JSON. - Object toJson() { - final Map json = {}; + Map toJson() { + final Map json = {}; - void addIfPresent(String fieldName, Object? value) { + void addIfPresent(String fieldName, dynamic value) { if (value != null) { json[fieldName] = value; } @@ -264,9 +283,9 @@ class Marker implements MapsObject { addIfPresent('consumeTapEvents', consumeTapEvents); addIfPresent('draggable', draggable); addIfPresent('flat', flat); - addIfPresent('icon', icon.toJson()); - addIfPresent('infoWindow', infoWindow._toJson()); - addIfPresent('position', position.toJson()); + addIfPresent('icon', icon?.toJson()); + addIfPresent('infoWindow', infoWindow?._toJson()); + addIfPresent('position', position?.toJson()); addIfPresent('rotation', rotation); addIfPresent('visible', visible); addIfPresent('zIndex', zIndex); @@ -277,7 +296,7 @@ class Marker implements MapsObject { bool operator ==(Object other) { if (identical(this, other)) return true; if (other.runtimeType != runtimeType) return false; - final Marker typedOther = other as Marker; + final Marker typedOther = other; return markerId == typedOther.markerId && alpha == typedOther.alpha && anchor == typedOther.anchor && diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker_updates.dart index 27257c628033..bb6ea8813ea3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker_updates.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/marker_updates.dart @@ -1,24 +1,110 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart' show setEquals; + import 'types.dart'; +import 'utils/marker.dart'; /// [Marker] update events to be applied to the [GoogleMap]. /// /// Used in [GoogleMapController] when the map is updated. // (Do not re-export) -class MarkerUpdates extends MapsObjectUpdates { +class MarkerUpdates { /// Computes [MarkerUpdates] given previous and current [Marker]s. - MarkerUpdates.from(Set previous, Set current) - : super.from(previous, current, objectName: 'marker'); + MarkerUpdates.from(Set previous, Set current) { + if (previous == null) { + previous = Set.identity(); + } + + if (current == null) { + current = Set.identity(); + } + + final Map previousMarkers = keyByMarkerId(previous); + final Map currentMarkers = keyByMarkerId(current); + + final Set prevMarkerIds = previousMarkers.keys.toSet(); + final Set currentMarkerIds = currentMarkers.keys.toSet(); + + Marker idToCurrentMarker(MarkerId id) { + return currentMarkers[id]; + } + + final Set _markerIdsToRemove = + prevMarkerIds.difference(currentMarkerIds); + + final Set _markersToAdd = currentMarkerIds + .difference(prevMarkerIds) + .map(idToCurrentMarker) + .toSet(); + + /// Returns `true` if [current] is not equals to previous one with the + /// same id. + bool hasChanged(Marker current) { + final Marker previous = previousMarkers[current.markerId]; + return current != previous; + } + + final Set _markersToChange = currentMarkerIds + .intersection(prevMarkerIds) + .map(idToCurrentMarker) + .where(hasChanged) + .toSet(); + + markersToAdd = _markersToAdd; + markerIdsToRemove = _markerIdsToRemove; + markersToChange = _markersToChange; + } /// Set of Markers to be added in this update. - Set get markersToAdd => objectsToAdd; + Set markersToAdd; /// Set of MarkerIds to be removed in this update. - Set get markerIdsToRemove => objectIdsToRemove.cast(); + Set markerIdsToRemove; /// Set of Markers to be changed in this update. - Set get markersToChange => objectsToChange; + Set markersToChange; + + /// Converts this object to something serializable in JSON. + Map toJson() { + final Map updateMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + updateMap[fieldName] = value; + } + } + + addIfNonNull('markersToAdd', serializeMarkerSet(markersToAdd)); + addIfNonNull('markersToChange', serializeMarkerSet(markersToChange)); + addIfNonNull('markerIdsToRemove', + markerIdsToRemove.map((MarkerId m) => m.value).toList()); + + return updateMap; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final MarkerUpdates typedOther = other; + return setEquals(markersToAdd, typedOther.markersToAdd) && + setEquals(markerIdsToRemove, typedOther.markerIdsToRemove) && + setEquals(markersToChange, typedOther.markersToChange); + } + + @override + int get hashCode => + hashValues(markersToAdd, markerIdsToRemove, markersToChange); + + @override + String toString() { + return '_MarkerUpdates{markersToAdd: $markersToAdd, ' + 'markerIdsToRemove: $markerIdsToRemove, ' + 'markersToChange: $markersToChange}'; + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/pattern_item.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/pattern_item.dart index 89f29d25e4cc..28c7ce9d33dd 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/pattern_item.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/pattern_item.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -10,14 +10,14 @@ class PatternItem { const PatternItem._(this._json); /// A dot used in the stroke pattern for a [Polyline]. - static const PatternItem dot = PatternItem._(['dot']); + static const PatternItem dot = PatternItem._(['dot']); /// A dash used in the stroke pattern for a [Polyline]. /// /// [length] has to be non-negative. static PatternItem dash(double length) { assert(length >= 0.0); - return PatternItem._(['dash', length]); + return PatternItem._(['dash', length]); } /// A gap used in the stroke pattern for a [Polyline]. @@ -25,11 +25,11 @@ class PatternItem { /// [length] has to be non-negative. static PatternItem gap(double length) { assert(length >= 0.0); - return PatternItem._(['gap', length]); + return PatternItem._(['gap', length]); } - final Object _json; + final dynamic _json; /// Converts this object to something serializable in JSON. - Object toJson() => _json; + dynamic toJson() => _json; } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart index 569bd4c1f553..3b5e25060faf 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon.dart @@ -1,11 +1,10 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart' show listEquals, VoidCallback; import 'package:flutter/material.dart' show Color, Colors; -import 'package:meta/meta.dart' show immutable; +import 'package:meta/meta.dart' show immutable, required; import 'types.dart'; @@ -13,22 +12,40 @@ import 'types.dart'; /// /// This does not have to be globally unique, only unique among the list. @immutable -class PolygonId extends MapsObjectId { +class PolygonId { /// Creates an immutable identifier for a [Polygon]. - const PolygonId(String value) : super(value); + PolygonId(this.value) : assert(value != null); + + /// value of the [PolygonId]. + final String value; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final PolygonId typedOther = other; + return value == typedOther.value; + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'PolygonId{value: $value}'; + } } /// Draws a polygon through geographical locations on the map. @immutable -class Polygon implements MapsObject { +class Polygon { /// Creates an immutable representation of a polygon through geographical locations on the map. const Polygon({ - required this.polygonId, + @required this.polygonId, this.consumeTapEvents = false, this.fillColor = Colors.black, this.geodesic = false, this.points = const [], - this.holes = const >[], this.strokeColor = Colors.black, this.strokeWidth = 10, this.visible = true, @@ -39,9 +56,6 @@ class Polygon implements MapsObject { /// Uniquely identifies a [Polygon]. final PolygonId polygonId; - @override - PolygonId get mapsId => polygonId; - /// True if the [Polygon] consumes tap events. /// /// If this is false, [onTap] callback will not be triggered. @@ -63,14 +77,6 @@ class Polygon implements MapsObject { /// default; to form a closed polygon, the start and end points must be the same. final List points; - /// To create an empty area within a polygon, you need to use holes. - /// To create the hole, the coordinates defining the hole path must be inside the polygon. - /// - /// The vertices of the holes to be cut out of polygon. - /// - /// Line segments of each points of hole are drawn inside polygon between consecutive hole points. - final List> holes; - /// True if the marker is visible. final bool visible; @@ -91,21 +97,20 @@ class Polygon implements MapsObject { final int zIndex; /// Callbacks to receive tap events for polygon placed on this map. - final VoidCallback? onTap; + final VoidCallback onTap; /// Creates a new [Polygon] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Polygon copyWith({ - bool? consumeTapEventsParam, - Color? fillColorParam, - bool? geodesicParam, - List? pointsParam, - List>? holesParam, - Color? strokeColorParam, - int? strokeWidthParam, - bool? visibleParam, - int? zIndexParam, - VoidCallback? onTapParam, + bool consumeTapEventsParam, + Color fillColorParam, + bool geodesicParam, + List pointsParam, + Color strokeColorParam, + int strokeWidthParam, + bool visibleParam, + int zIndexParam, + VoidCallback onTapParam, }) { return Polygon( polygonId: polygonId, @@ -113,7 +118,6 @@ class Polygon implements MapsObject { fillColor: fillColorParam ?? fillColor, geodesic: geodesicParam ?? geodesic, points: pointsParam ?? points, - holes: holesParam ?? holes, strokeColor: strokeColorParam ?? strokeColor, strokeWidth: strokeWidthParam ?? strokeWidth, visible: visibleParam ?? visible, @@ -128,10 +132,10 @@ class Polygon implements MapsObject { } /// Converts this object to something serializable in JSON. - Object toJson() { - final Map json = {}; + dynamic toJson() { + final Map json = {}; - void addIfPresent(String fieldName, Object? value) { + void addIfPresent(String fieldName, dynamic value) { if (value != null) { json[fieldName] = value; } @@ -150,10 +154,6 @@ class Polygon implements MapsObject { json['points'] = _pointsToJson(); } - if (holes != null) { - json['holes'] = _holesToJson(); - } - return json; } @@ -161,13 +161,12 @@ class Polygon implements MapsObject { bool operator ==(Object other) { if (identical(this, other)) return true; if (other.runtimeType != runtimeType) return false; - final Polygon typedOther = other as Polygon; + final Polygon typedOther = other; return polygonId == typedOther.polygonId && consumeTapEvents == typedOther.consumeTapEvents && fillColor == typedOther.fillColor && geodesic == typedOther.geodesic && listEquals(points, typedOther.points) && - const DeepCollectionEquality().equals(holes, typedOther.holes) && visible == typedOther.visible && strokeColor == typedOther.strokeColor && strokeWidth == typedOther.strokeWidth && @@ -177,23 +176,11 @@ class Polygon implements MapsObject { @override int get hashCode => polygonId.hashCode; - Object _pointsToJson() { - final List result = []; + dynamic _pointsToJson() { + final List result = []; for (final LatLng point in points) { result.add(point.toJson()); } return result; } - - List> _holesToJson() { - final List> result = >[]; - for (final List hole in holes) { - final List jsonHole = []; - for (final LatLng point in hole) { - jsonHole.add(point.toJson()); - } - result.add(jsonHole); - } - return result; - } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon_updates.dart index 8b62141ce03c..cc8b8e26c896 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon_updates.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polygon_updates.dart @@ -1,24 +1,110 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart' show setEquals; + import 'types.dart'; +import 'utils/polygon.dart'; /// [Polygon] update events to be applied to the [GoogleMap]. /// /// Used in [GoogleMapController] when the map is updated. // (Do not re-export) -class PolygonUpdates extends MapsObjectUpdates { +class PolygonUpdates { /// Computes [PolygonUpdates] given previous and current [Polygon]s. - PolygonUpdates.from(Set previous, Set current) - : super.from(previous, current, objectName: 'polygon'); + PolygonUpdates.from(Set previous, Set current) { + if (previous == null) { + previous = Set.identity(); + } + + if (current == null) { + current = Set.identity(); + } + + final Map previousPolygons = keyByPolygonId(previous); + final Map currentPolygons = keyByPolygonId(current); + + final Set prevPolygonIds = previousPolygons.keys.toSet(); + final Set currentPolygonIds = currentPolygons.keys.toSet(); + + Polygon idToCurrentPolygon(PolygonId id) { + return currentPolygons[id]; + } + + final Set _polygonIdsToRemove = + prevPolygonIds.difference(currentPolygonIds); + + final Set _polygonsToAdd = currentPolygonIds + .difference(prevPolygonIds) + .map(idToCurrentPolygon) + .toSet(); + + /// Returns `true` if [current] is not equals to previous one with the + /// same id. + bool hasChanged(Polygon current) { + final Polygon previous = previousPolygons[current.polygonId]; + return current != previous; + } + + final Set _polygonsToChange = currentPolygonIds + .intersection(prevPolygonIds) + .map(idToCurrentPolygon) + .where(hasChanged) + .toSet(); + + polygonsToAdd = _polygonsToAdd; + polygonIdsToRemove = _polygonIdsToRemove; + polygonsToChange = _polygonsToChange; + } /// Set of Polygons to be added in this update. - Set get polygonsToAdd => objectsToAdd; + Set polygonsToAdd; /// Set of PolygonIds to be removed in this update. - Set get polygonIdsToRemove => objectIdsToRemove.cast(); + Set polygonIdsToRemove; /// Set of Polygons to be changed in this update. - Set get polygonsToChange => objectsToChange; + Set polygonsToChange; + + /// Converts this object to something serializable in JSON. + Map toJson() { + final Map updateMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + updateMap[fieldName] = value; + } + } + + addIfNonNull('polygonsToAdd', serializePolygonSet(polygonsToAdd)); + addIfNonNull('polygonsToChange', serializePolygonSet(polygonsToChange)); + addIfNonNull('polygonIdsToRemove', + polygonIdsToRemove.map((PolygonId m) => m.value).toList()); + + return updateMap; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final PolygonUpdates typedOther = other; + return setEquals(polygonsToAdd, typedOther.polygonsToAdd) && + setEquals(polygonIdsToRemove, typedOther.polygonIdsToRemove) && + setEquals(polygonsToChange, typedOther.polygonsToChange); + } + + @override + int get hashCode => + hashValues(polygonsToAdd, polygonIdsToRemove, polygonsToChange); + + @override + String toString() { + return '_PolygonUpdates{polygonsToAdd: $polygonsToAdd, ' + 'polygonIdsToRemove: $polygonIdsToRemove, ' + 'polygonsToChange: $polygonsToChange}'; + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart index c324aeb5f492..ae5c3b976352 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline.dart @@ -1,10 +1,10 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/foundation.dart' show listEquals, VoidCallback; import 'package:flutter/material.dart' show Color, Colors; -import 'package:meta/meta.dart' show immutable; +import 'package:meta/meta.dart' show immutable, required; import 'types.dart'; @@ -12,19 +12,38 @@ import 'types.dart'; /// /// This does not have to be globally unique, only unique among the list. @immutable -class PolylineId extends MapsObjectId { +class PolylineId { /// Creates an immutable object representing a [PolylineId] among [GoogleMap] polylines. /// /// An [AssertionError] will be thrown if [value] is null. - const PolylineId(String value) : super(value); + PolylineId(this.value) : assert(value != null); + + /// value of the [PolylineId]. + final String value; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final PolylineId typedOther = other; + return value == typedOther.value; + } + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'PolylineId{value: $value}'; + } } /// Draws a line through geographical locations on the map. @immutable -class Polyline implements MapsObject { +class Polyline { /// Creates an immutable object representing a line drawn through geographical locations on the map. const Polyline({ - required this.polylineId, + @required this.polylineId, this.consumeTapEvents = false, this.color = Colors.black, this.endCap = Cap.buttCap, @@ -42,9 +61,6 @@ class Polyline implements MapsObject { /// Uniquely identifies a [Polyline]. final PolylineId polylineId; - @override - PolylineId get mapsId => polylineId; - /// True if the [Polyline] consumes tap events. /// /// If this is false, [onTap] callback will not be triggered. @@ -113,23 +129,23 @@ class Polyline implements MapsObject { final int zIndex; /// Callbacks to receive tap events for polyline placed on this map. - final VoidCallback? onTap; + final VoidCallback onTap; /// Creates a new [Polyline] object whose values are the same as this instance, /// unless overwritten by the specified parameters. Polyline copyWith({ - Color? colorParam, - bool? consumeTapEventsParam, - Cap? endCapParam, - bool? geodesicParam, - JointType? jointTypeParam, - List? patternsParam, - List? pointsParam, - Cap? startCapParam, - bool? visibleParam, - int? widthParam, - int? zIndexParam, - VoidCallback? onTapParam, + Color colorParam, + bool consumeTapEventsParam, + Cap endCapParam, + bool geodesicParam, + JointType jointTypeParam, + List patternsParam, + List pointsParam, + Cap startCapParam, + bool visibleParam, + int widthParam, + int zIndexParam, + VoidCallback onTapParam, }) { return Polyline( polylineId: polylineId, @@ -158,10 +174,10 @@ class Polyline implements MapsObject { } /// Converts this object to something serializable in JSON. - Object toJson() { - final Map json = {}; + dynamic toJson() { + final Map json = {}; - void addIfPresent(String fieldName, Object? value) { + void addIfPresent(String fieldName, dynamic value) { if (value != null) { json[fieldName] = value; } @@ -170,10 +186,10 @@ class Polyline implements MapsObject { addIfPresent('polylineId', polylineId.value); addIfPresent('consumeTapEvents', consumeTapEvents); addIfPresent('color', color.value); - addIfPresent('endCap', endCap.toJson()); + addIfPresent('endCap', endCap?.toJson()); addIfPresent('geodesic', geodesic); - addIfPresent('jointType', jointType.value); - addIfPresent('startCap', startCap.toJson()); + addIfPresent('jointType', jointType?.value); + addIfPresent('startCap', startCap?.toJson()); addIfPresent('visible', visible); addIfPresent('width', width); addIfPresent('zIndex', zIndex); @@ -193,7 +209,7 @@ class Polyline implements MapsObject { bool operator ==(Object other) { if (identical(this, other)) return true; if (other.runtimeType != runtimeType) return false; - final Polyline typedOther = other as Polyline; + final Polyline typedOther = other; return polylineId == typedOther.polylineId && consumeTapEvents == typedOther.consumeTapEvents && color == typedOther.color && @@ -211,16 +227,16 @@ class Polyline implements MapsObject { @override int get hashCode => polylineId.hashCode; - Object _pointsToJson() { - final List result = []; + dynamic _pointsToJson() { + final List result = []; for (final LatLng point in points) { result.add(point.toJson()); } return result; } - Object _patternToJson() { - final List result = []; + dynamic _patternToJson() { + final List result = []; for (final PatternItem patternItem in patterns) { if (patternItem != null) { result.add(patternItem.toJson()); diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline_updates.dart index 30cd99f73229..f871928c0ac4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline_updates.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/polyline_updates.dart @@ -1,25 +1,111 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:ui' show hashValues; + +import 'package:flutter/foundation.dart' show setEquals; + +import 'utils/polyline.dart'; import 'types.dart'; /// [Polyline] update events to be applied to the [GoogleMap]. /// /// Used in [GoogleMapController] when the map is updated. // (Do not re-export) -class PolylineUpdates extends MapsObjectUpdates { +class PolylineUpdates { /// Computes [PolylineUpdates] given previous and current [Polyline]s. - PolylineUpdates.from(Set previous, Set current) - : super.from(previous, current, objectName: 'polyline'); + PolylineUpdates.from(Set previous, Set current) { + if (previous == null) { + previous = Set.identity(); + } + + if (current == null) { + current = Set.identity(); + } + + final Map previousPolylines = + keyByPolylineId(previous); + final Map currentPolylines = keyByPolylineId(current); + + final Set prevPolylineIds = previousPolylines.keys.toSet(); + final Set currentPolylineIds = currentPolylines.keys.toSet(); + + Polyline idToCurrentPolyline(PolylineId id) { + return currentPolylines[id]; + } + + final Set _polylineIdsToRemove = + prevPolylineIds.difference(currentPolylineIds); + + final Set _polylinesToAdd = currentPolylineIds + .difference(prevPolylineIds) + .map(idToCurrentPolyline) + .toSet(); + + /// Returns `true` if [current] is not equals to previous one with the + /// same id. + bool hasChanged(Polyline current) { + final Polyline previous = previousPolylines[current.polylineId]; + return current != previous; + } + + final Set _polylinesToChange = currentPolylineIds + .intersection(prevPolylineIds) + .map(idToCurrentPolyline) + .where(hasChanged) + .toSet(); + + polylinesToAdd = _polylinesToAdd; + polylineIdsToRemove = _polylineIdsToRemove; + polylinesToChange = _polylinesToChange; + } /// Set of Polylines to be added in this update. - Set get polylinesToAdd => objectsToAdd; + Set polylinesToAdd; /// Set of PolylineIds to be removed in this update. - Set get polylineIdsToRemove => - objectIdsToRemove.cast(); + Set polylineIdsToRemove; /// Set of Polylines to be changed in this update. - Set get polylinesToChange => objectsToChange; + Set polylinesToChange; + + /// Converts this object to something serializable in JSON. + Map toJson() { + final Map updateMap = {}; + + void addIfNonNull(String fieldName, dynamic value) { + if (value != null) { + updateMap[fieldName] = value; + } + } + + addIfNonNull('polylinesToAdd', serializePolylineSet(polylinesToAdd)); + addIfNonNull('polylinesToChange', serializePolylineSet(polylinesToChange)); + addIfNonNull('polylineIdsToRemove', + polylineIdsToRemove.map((PolylineId m) => m.value).toList()); + + return updateMap; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final PolylineUpdates typedOther = other; + return setEquals(polylinesToAdd, typedOther.polylinesToAdd) && + setEquals(polylineIdsToRemove, typedOther.polylineIdsToRemove) && + setEquals(polylinesToChange, typedOther.polylinesToChange); + } + + @override + int get hashCode => + hashValues(polylinesToAdd, polylineIdsToRemove, polylinesToChange); + + @override + String toString() { + return '_PolylineUpdates{polylinesToAdd: $polylinesToAdd, ' + 'polylineIdsToRemove: $polylineIdsToRemove, ' + 'polylinesToChange: $polylinesToChange}'; + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/screen_coordinate.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/screen_coordinate.dart index 8c9c083913ce..965db7969bc2 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/screen_coordinate.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/screen_coordinate.dart @@ -1,10 +1,10 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:ui' show hashValues; -import 'package:meta/meta.dart' show immutable; +import 'package:meta/meta.dart' show immutable, required; /// Represents a point coordinate in the [GoogleMap]'s view. /// @@ -15,8 +15,8 @@ import 'package:meta/meta.dart' show immutable; class ScreenCoordinate { /// Creates an immutable representation of a point coordinate in the [GoogleMap]'s view. const ScreenCoordinate({ - required this.x, - required this.y, + @required this.x, + @required this.y, }); /// Represents the number of pixels from the left of the [GoogleMap]. @@ -26,7 +26,7 @@ class ScreenCoordinate { final int y; /// Converts this object to something serializable in JSON. - Object toJson() { + dynamic toJson() { return { "x": x, "y": y, diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile.dart deleted file mode 100644 index d602b127f06c..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile.dart +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:typed_data'; -import 'package:meta/meta.dart' show immutable; - -/// Contains information about a Tile that is returned by a [TileProvider]. -@immutable -class Tile { - /// Creates an immutable representation of a [Tile] to draw by [TileProvider]. - const Tile(this.width, this.height, this.data); - - /// The width of the image encoded by data in logical pixels. - final int width; - - /// The height of the image encoded by data in logical pixels. - final int height; - - /// A byte array containing the image data. - /// - /// The image data format must be natively supported for decoding by the platform. - /// e.g on Android it can only be one of the [supported image formats for decoding](https://developer.android.com/guide/topics/media/media-formats#image-formats). - final Uint8List? data; - - /// Converts this object to JSON. - Object toJson() { - final Map json = {}; - - void addIfPresent(String fieldName, Object? value) { - if (value != null) { - json[fieldName] = value; - } - } - - addIfPresent('width', width); - addIfPresent('height', height); - addIfPresent('data', data); - - return json; - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_overlay.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_overlay.dart deleted file mode 100644 index 8cdd2c4699e1..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_overlay.dart +++ /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. - -import 'dart:ui' show hashValues; - -import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart' show immutable; - -import 'types.dart'; - -/// Uniquely identifies a [TileOverlay] among [GoogleMap] tile overlays. -@immutable -class TileOverlayId extends MapsObjectId { - /// Creates an immutable identifier for a [TileOverlay]. - const TileOverlayId(String value) : super(value); -} - -/// A set of images which are displayed on top of the base map tiles. -/// -/// These tiles may be transparent, allowing you to add features to existing maps. -/// -/// ## Tile Coordinates -/// -/// Note that the world is projected using the Mercator projection -/// (see [Wikipedia](https://en.wikipedia.org/wiki/Mercator_projection)) with the left (west) side -/// of the map corresponding to -180 degrees of longitude and the right (east) side of the map -/// corresponding to 180 degrees of longitude. To make the map square, the top (north) side of the -/// map corresponds to 85.0511 degrees of latitude and the bottom (south) side of the map -/// corresponds to -85.0511 degrees of latitude. Areas outside this latitude range are not rendered. -/// -/// At each zoom level, the map is divided into tiles and only the tiles that overlap the screen are -/// downloaded and rendered. Each tile is square and the map is divided into tiles as follows: -/// -/// * At zoom level 0, one tile represents the entire world. The coordinates of that tile are -/// (x, y) = (0, 0). -/// * At zoom level 1, the world is divided into 4 tiles arranged in a 2 x 2 grid. -/// * ... -/// * At zoom level N, the world is divided into 4N tiles arranged in a 2N x 2N grid. -/// -/// Note that the minimum zoom level that the camera supports (which can depend on various factors) -/// is GoogleMap.getMinZoomLevel and the maximum zoom level is GoogleMap.getMaxZoomLevel. -/// -/// The coordinates of the tiles are measured from the top left (northwest) corner of the map. -/// At zoom level N, the x values of the tile coordinates range from 0 to 2N - 1 and increase from -/// west to east and the y values range from 0 to 2N - 1 and increase from north to south. -/// -class TileOverlay implements MapsObject { - /// Creates an immutable representation of a [TileOverlay] to draw on [GoogleMap]. - const TileOverlay({ - required this.tileOverlayId, - this.fadeIn = true, - this.tileProvider, - this.transparency = 0.0, - this.zIndex = 0, - this.visible = true, - this.tileSize = 256, - }) : assert(transparency >= 0.0 && transparency <= 1.0); - - /// Uniquely identifies a [TileOverlay]. - final TileOverlayId tileOverlayId; - - @override - TileOverlayId get mapsId => tileOverlayId; - - /// Whether the tiles should fade in. The default is true. - final bool fadeIn; - - /// The tile provider to use for this tile overlay. - final TileProvider? tileProvider; - - /// The transparency of the tile overlay. The default transparency is 0 (opaque). - final double transparency; - - /// The tile overlay's zIndex, i.e., the order in which it will be drawn where - /// overlays with larger values are drawn above those with lower values - final int zIndex; - - /// The visibility for the tile overlay. The default visibility is true. - final bool visible; - - /// Specifies the number of logical pixels (not points) that the returned tile images will prefer - /// to display as. iOS only. - /// - /// Defaults to 256, which is the traditional size of Google Maps tiles. - /// As an example, an application developer may wish to provide retina tiles (512 pixel edge length) - /// on retina devices, to keep the same number of tiles per view as the default value of 256 - /// would give on a non-retina device. - final int tileSize; - - /// Creates a new [TileOverlay] object whose values are the same as this instance, - /// unless overwritten by the specified parameters. - TileOverlay copyWith({ - bool? fadeInParam, - TileProvider? tileProviderParam, - double? transparencyParam, - int? zIndexParam, - bool? visibleParam, - int? tileSizeParam, - }) { - return TileOverlay( - tileOverlayId: tileOverlayId, - fadeIn: fadeInParam ?? fadeIn, - tileProvider: tileProviderParam ?? tileProvider, - transparency: transparencyParam ?? transparency, - zIndex: zIndexParam ?? zIndex, - visible: visibleParam ?? visible, - tileSize: tileSizeParam ?? tileSize, - ); - } - - TileOverlay clone() => copyWith(); - - /// Converts this object to JSON. - Object toJson() { - final Map json = {}; - - void addIfPresent(String fieldName, Object? value) { - if (value != null) { - json[fieldName] = value; - } - } - - addIfPresent('tileOverlayId', tileOverlayId.value); - addIfPresent('fadeIn', fadeIn); - addIfPresent('transparency', transparency); - addIfPresent('zIndex', zIndex); - addIfPresent('visible', visible); - addIfPresent('tileSize', tileSize); - - return json; - } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - return other is TileOverlay && - tileOverlayId == other.tileOverlayId && - fadeIn == other.fadeIn && - tileProvider == other.tileProvider && - transparency == other.transparency && - zIndex == other.zIndex && - visible == other.visible && - tileSize == other.tileSize; - } - - @override - int get hashCode => hashValues(tileOverlayId, fadeIn, tileProvider, - transparency, zIndex, visible, tileSize); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_overlay_updates.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_overlay_updates.dart deleted file mode 100644 index e40db7da10fe..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_overlay_updates.dart +++ /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. - -import 'types.dart'; - -/// Update specification for a set of [TileOverlay]s. -class TileOverlayUpdates extends MapsObjectUpdates { - /// Computes [TileOverlayUpdates] given previous and current [TileOverlay]s. - TileOverlayUpdates.from(Set previous, Set current) - : super.from(previous, current, objectName: 'tileOverlay'); - - /// Set of TileOverlays to be added in this update. - Set get tileOverlaysToAdd => objectsToAdd; - - /// Set of TileOverlayIds to be removed in this update. - Set get tileOverlayIdsToRemove => - objectIdsToRemove.cast(); - - /// Set of TileOverlays to be changed in this update. - Set get tileOverlaysToChange => objectsToChange; -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_provider.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_provider.dart deleted file mode 100644 index dfe6937e24a4..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/tile_provider.dart +++ /dev/null @@ -1,16 +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 'types.dart'; - -/// An interface for a class that provides the tile images for a TileOverlay. -abstract class TileProvider { - /// Stub tile that is used to indicate that no tile exists for a specific tile coordinate. - static const Tile noTile = Tile(-1, -1, null); - - /// Returns the tile to be used for this tile coordinate. - /// - /// See [TileOverlay] for the specification of tile coordinates. - Future getTile(int x, int y, int? zoom); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart index 5e2e4c234ccf..b7787cc5634d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/types.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -9,10 +9,10 @@ export 'camera.dart'; export 'cap.dart'; export 'circle_updates.dart'; export 'circle.dart'; +export 'heatmap_updates.dart'; +export 'heatmap.dart'; export 'joint_type.dart'; export 'location.dart'; -export 'maps_object_updates.dart'; -export 'maps_object.dart'; export 'marker_updates.dart'; export 'marker.dart'; export 'pattern_item.dart'; @@ -21,14 +21,11 @@ export 'polygon.dart'; export 'polyline_updates.dart'; export 'polyline.dart'; export 'screen_coordinate.dart'; -export 'tile.dart'; -export 'tile_overlay.dart'; -export 'tile_provider.dart'; export 'ui.dart'; // Export the utils, they're used by the Widget export 'utils/circle.dart'; +export 'utils/heatmap.dart'; export 'utils/marker.dart'; export 'utils/polygon.dart'; export 'utils/polyline.dart'; -export 'utils/tile_overlay.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/ui.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/ui.dart index 38c34fcfd27f..8d84171bac03 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/ui.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/ui.dart @@ -1,4 +1,4 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -39,19 +39,19 @@ class CameraTargetBounds { /// The geographical bounding box for the map camera target. /// /// A null value means the camera target is unbounded. - final LatLngBounds? bounds; + final LatLngBounds bounds; /// Unbounded camera target. static const CameraTargetBounds unbounded = CameraTargetBounds(null); /// Converts this object to something serializable in JSON. - Object toJson() => [bounds?.toJson()]; + dynamic toJson() => [bounds?.toJson()]; @override - bool operator ==(Object other) { + bool operator ==(dynamic other) { if (identical(this, other)) return true; if (runtimeType != other.runtimeType) return false; - final CameraTargetBounds typedOther = other as CameraTargetBounds; + final CameraTargetBounds typedOther = other; return bounds == typedOther.bounds; } @@ -76,23 +76,23 @@ class MinMaxZoomPreference { : assert(minZoom == null || maxZoom == null || minZoom <= maxZoom); /// The preferred minimum zoom level or null, if unbounded from below. - final double? minZoom; + final double minZoom; /// The preferred maximum zoom level or null, if unbounded from above. - final double? maxZoom; + final double maxZoom; /// Unbounded zooming. static const MinMaxZoomPreference unbounded = MinMaxZoomPreference(null, null); /// Converts this object to something serializable in JSON. - Object toJson() => [minZoom, maxZoom]; + dynamic toJson() => [minZoom, maxZoom]; @override - bool operator ==(Object other) { + bool operator ==(dynamic other) { if (identical(this, other)) return true; if (runtimeType != other.runtimeType) return false; - final MinMaxZoomPreference typedOther = other as MinMaxZoomPreference; + final MinMaxZoomPreference typedOther = other; return minZoom == typedOther.minZoom && maxZoom == typedOther.maxZoom; } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/circle.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/circle.dart index bf1754fdf399..5c3af96f8e02 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/circle.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/circle.dart @@ -1,16 +1,22 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../types.dart'; -import 'maps_object.dart'; /// Converts an [Iterable] of Circles in a Map of CircleId -> Circle. Map keyByCircleId(Iterable circles) { - return keyByMapsObjectId(circles).cast(); + if (circles == null) { + return {}; + } + return Map.fromEntries(circles.map((Circle circle) => + MapEntry(circle.circleId, circle.clone()))); } /// Converts a Set of Circles into something serializable in JSON. -Object serializeCircleSet(Set circles) { - return serializeMapsObjectSet(circles); +List> serializeCircleSet(Set circles) { + if (circles == null) { + return null; + } + return circles.map>((Circle p) => p.toJson()).toList(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/heatmap.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/heatmap.dart new file mode 100644 index 000000000000..1261274659a1 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/heatmap.dart @@ -0,0 +1,22 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../types.dart'; + +/// Converts an [Iterable] of Heatmaps in a Map of HeatmapId -> Heatmap. +Map keyByHeatmapId(Iterable heatmaps) { + if (heatmaps == null) { + return {}; + } + return Map.fromEntries(heatmaps.map((Heatmap heatmap) => + MapEntry(heatmap.heatmapId, heatmap.clone()))); +} + +/// Converts a Set of Heatmaps into something serializable in JSON. +List> serializeHeatmapSet(Set heatmaps) { + if (heatmaps == null) { + return null; + } + return heatmaps.map>((Heatmap p) => p.toJson()).toList(); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/maps_object.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/maps_object.dart deleted file mode 100644 index da5a49825c7f..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/maps_object.dart +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import '../maps_object.dart'; - -/// Converts an [Iterable] of [MapsObject]s in a Map of [MapObjectId] -> [MapObject]. -Map, T> keyByMapsObjectId( - Iterable objects) { - return Map, T>.fromEntries(objects.map((T object) => - MapEntry, T>( - object.mapsId as MapsObjectId, object.clone()))); -} - -/// Converts a Set of [MapsObject]s into something serializable in JSON. -Object serializeMapsObjectSet(Set mapsObjects) { - return mapsObjects.map((MapsObject p) => p.toJson()).toList(); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/marker.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/marker.dart index 4be3f2a2f9a4..7a2c76d8055b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/marker.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/marker.dart @@ -1,16 +1,22 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../types.dart'; -import 'maps_object.dart'; /// Converts an [Iterable] of Markers in a Map of MarkerId -> Marker. Map keyByMarkerId(Iterable markers) { - return keyByMapsObjectId(markers).cast(); + if (markers == null) { + return {}; + } + return Map.fromEntries(markers.map((Marker marker) => + MapEntry(marker.markerId, marker.clone()))); } /// Converts a Set of Markers into something serializable in JSON. -Object serializeMarkerSet(Set markers) { - return serializeMapsObjectSet(markers); +List> serializeMarkerSet(Set markers) { + if (markers == null) { + return null; + } + return markers.map>((Marker m) => m.toJson()).toList(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polygon.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polygon.dart index ba4ce7d6f55f..9434ddaa077d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polygon.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polygon.dart @@ -1,16 +1,22 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../types.dart'; -import 'maps_object.dart'; /// Converts an [Iterable] of Polygons in a Map of PolygonId -> Polygon. Map keyByPolygonId(Iterable polygons) { - return keyByMapsObjectId(polygons).cast(); + if (polygons == null) { + return {}; + } + return Map.fromEntries(polygons.map((Polygon polygon) => + MapEntry(polygon.polygonId, polygon.clone()))); } /// Converts a Set of Polygons into something serializable in JSON. -Object serializePolygonSet(Set polygons) { - return serializeMapsObjectSet(polygons); +List> serializePolygonSet(Set polygons) { + if (polygons == null) { + return null; + } + return polygons.map>((Polygon p) => p.toJson()).toList(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polyline.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polyline.dart index 8c188b021b2f..9cef6319ddb5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polyline.dart +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/polyline.dart @@ -1,16 +1,25 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../types.dart'; -import 'maps_object.dart'; /// Converts an [Iterable] of Polylines in a Map of PolylineId -> Polyline. Map keyByPolylineId(Iterable polylines) { - return keyByMapsObjectId(polylines).cast(); + if (polylines == null) { + return {}; + } + return Map.fromEntries(polylines.map( + (Polyline polyline) => MapEntry( + polyline.polylineId, polyline.clone()))); } /// Converts a Set of Polylines into something serializable in JSON. -Object serializePolylineSet(Set polylines) { - return serializeMapsObjectSet(polylines); +List> serializePolylineSet(Set polylines) { + if (polylines == null) { + return null; + } + return polylines + .map>((Polyline p) => p.toJson()) + .toList(); } diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/tile_overlay.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/tile_overlay.dart deleted file mode 100644 index fae61a4b4433..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/lib/src/types/utils/tile_overlay.dart +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import '../types.dart'; -import 'maps_object.dart'; - -/// Converts an [Iterable] of TileOverlay in a Map of TileOverlayId -> TileOverlay. -Map keyTileOverlayId( - Iterable tileOverlays) { - return keyByMapsObjectId(tileOverlays) - .cast(); -} - -/// Converts a Set of TileOverlays into something serializable in JSON. -Object serializeTileOverlaySet(Set tileOverlays) { - return serializeMapsObjectSet(tileOverlays); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_platform_interface/pubspec.yaml index 5b278a812a8e..346d96050166 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 @@ -1,25 +1,23 @@ name: google_maps_flutter_platform_interface description: A common platform interface for the google_maps_flutter plugin. -repository: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter_platform_interface -issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 +homepage: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter_platform_interface # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 2.0.4 - -environment: - sdk: '>=2.12.0 <3.0.0' - flutter: ">=2.0.0" +version: 1.0.1 dependencies: - collection: ^1.15.0 flutter: sdk: flutter - meta: ^1.3.0 - plugin_platform_interface: ^2.0.0 - stream_transform: ^2.0.0 + meta: ^1.0.5 + plugin_platform_interface: ^1.0.1 + stream_transform: ^1.2.0 dev_dependencies: flutter_test: sdk: flutter - mockito: ^5.0.0 - pedantic: ^1.10.0 + mockito: ^4.1.1 + pedantic: ^1.8.0 + +environment: + sdk: ">=2.0.0-dev.28.0 <3.0.0" + flutter: ">=1.9.1+hotfix.4 <2.0.0" diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/method_channel/method_channel_google_maps_flutter_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/method_channel/method_channel_google_maps_flutter_test.dart deleted file mode 100644 index 19e81c960839..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/method_channel/method_channel_google_maps_flutter_test.dart +++ /dev/null @@ -1,72 +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:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:google_maps_flutter_platform_interface/src/method_channel/method_channel_google_maps_flutter.dart'; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('$MethodChannelGoogleMapsFlutter', () { - late List log; - - setUp(() async { - log = []; - }); - - /// Initializes a map with the given ID and canned responses, logging all - /// calls to [log]. - void configureMockMap( - MethodChannelGoogleMapsFlutter maps, { - required int mapId, - required Future? Function(MethodCall call) handler, - }) { - maps - .ensureChannelInitialized(mapId) - .setMockMethodCallHandler((MethodCall methodCall) { - log.add(methodCall.method); - return handler(methodCall); - }); - } - - // Calls each method that uses invokeMethod with a return type other than - // void to ensure that the casting/nullability handling succeeds. - // - // TODO(stuartmorgan): Remove this once there is real test coverage of - // each method, since that would cover this issue. - test('non-void invokeMethods handle types correctly', () async { - const int mapId = 0; - final MethodChannelGoogleMapsFlutter maps = - MethodChannelGoogleMapsFlutter(); - configureMockMap(maps, mapId: mapId, - handler: (MethodCall methodCall) async { - switch (methodCall.method) { - case 'map#getLatLng': - return [1.0, 2.0]; - case 'markers#isInfoWindowShown': - return true; - case 'map#getZoomLevel': - return 2.5; - case 'map#takeSnapshot': - return null; - } - }); - - await maps.getLatLng(ScreenCoordinate(x: 0, y: 0), mapId: mapId); - await maps.isMarkerInfoWindowShown(MarkerId(''), mapId: mapId); - await maps.getZoomLevel(mapId: mapId); - await maps.takeSnapshot(mapId: mapId); - // Check that all the invokeMethod calls happened. - expect(log, [ - 'map#getLatLng', - 'markers#isInfoWindowShown', - 'map#getZoomLevel', - 'map#takeSnapshot', - ]); - }); - }); -} 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..a003b94d544c 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 @@ -1,8 +1,9 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. +// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:mockito/mockito.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; @@ -35,6 +36,29 @@ void main() { GoogleMapsFlutterPlatform.instance = ExtendsGoogleMapsFlutterPlatform(); }); }); + + group('$MethodChannelGoogleMapsFlutter', () { + const MethodChannel channel = + MethodChannel('plugins.flutter.io/google_maps_flutter'); + final List log = []; + channel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + +// final MethodChannelGoogleMapsFlutter map = MethodChannelGoogleMapsFlutter(0); + + tearDown(() { + log.clear(); + }); + + test('foo', () async { +// await map.foo(); + expect( + log, + [], + ); + }); + }); } class GoogleMapsFlutterPlatformMock extends Mock diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/bitmap_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/bitmap_test.dart deleted file mode 100644 index 6d02b2c630df..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/bitmap_test.dart +++ /dev/null @@ -1,167 +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:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('$BitmapDescriptor', () { - test('toJson / fromJson', () { - final descriptor = - BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan); - final json = descriptor.toJson(); - - // Rehydrate a new bitmap descriptor... - // ignore: deprecated_member_use_from_same_package - final descriptorFromJson = BitmapDescriptor.fromJson(json); - - expect(descriptorFromJson, isNot(descriptor)); // New instance - expect(identical(descriptorFromJson.toJson(), json), isTrue); // Same JSON - }); - - group('fromJson validation', () { - group('type validation', () { - test('correct type', () { - expect(BitmapDescriptor.fromJson(['defaultMarker']), - isA()); - }); - test('wrong type', () { - expect(() { - BitmapDescriptor.fromJson(['bogusType']); - }, throwsAssertionError); - }); - }); - group('defaultMarker', () { - test('hue is null', () { - expect(BitmapDescriptor.fromJson(['defaultMarker']), - isA()); - }); - test('hue is number', () { - expect(BitmapDescriptor.fromJson(['defaultMarker', 158]), - isA()); - }); - test('hue is not number', () { - expect(() { - BitmapDescriptor.fromJson(['defaultMarker', 'nope']); - }, throwsAssertionError); - }); - test('hue is out of range', () { - expect(() { - BitmapDescriptor.fromJson(['defaultMarker', -1]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson(['defaultMarker', 361]); - }, throwsAssertionError); - }); - }); - group('fromBytes', () { - test('with bytes', () { - expect( - BitmapDescriptor.fromJson([ - 'fromBytes', - Uint8List.fromList([1, 2, 3]) - ]), - isA()); - }); - test('without bytes', () { - expect(() { - BitmapDescriptor.fromJson(['fromBytes', null]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson(['fromBytes', []]); - }, throwsAssertionError); - }); - }); - group('fromAsset', () { - test('name is passed', () { - expect(BitmapDescriptor.fromJson(['fromAsset', 'some/path.png']), - isA()); - }); - test('name cannot be null or empty', () { - expect(() { - BitmapDescriptor.fromJson(['fromAsset', null]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson(['fromAsset', '']); - }, throwsAssertionError); - }); - test('package is passed', () { - expect( - BitmapDescriptor.fromJson( - ['fromAsset', 'some/path.png', 'some_package']), - isA()); - }); - test('package cannot be null or empty', () { - expect(() { - BitmapDescriptor.fromJson(['fromAsset', 'some/path.png', null]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson(['fromAsset', 'some/path.png', '']); - }, throwsAssertionError); - }); - }); - group('fromAssetImage', () { - test('name and dpi passed', () { - expect( - BitmapDescriptor.fromJson( - ['fromAssetImage', 'some/path.png', 1.0]), - isA()); - }); - test('name cannot be null or empty', () { - expect(() { - BitmapDescriptor.fromJson(['fromAssetImage', null, 1.0]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson(['fromAssetImage', '', 1.0]); - }, throwsAssertionError); - }); - test('dpi must be number', () { - expect(() { - BitmapDescriptor.fromJson( - ['fromAssetImage', 'some/path.png', null]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson( - ['fromAssetImage', 'some/path.png', 'one']); - }, throwsAssertionError); - }); - test('with optional [width, height] List', () { - expect( - BitmapDescriptor.fromJson([ - 'fromAssetImage', - 'some/path.png', - 1.0, - [640, 480] - ]), - isA()); - }); - test( - 'optional [width, height] List cannot be null or not contain 2 elements', - () { - expect(() { - BitmapDescriptor.fromJson( - ['fromAssetImage', 'some/path.png', 1.0, null]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson( - ['fromAssetImage', 'some/path.png', 1.0, []]); - }, throwsAssertionError); - expect(() { - BitmapDescriptor.fromJson([ - 'fromAssetImage', - 'some/path.png', - 1.0, - [640, 480, 1024] - ]); - }, throwsAssertionError); - }); - }); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/camera_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/camera_test.dart deleted file mode 100644 index 11665d904556..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/camera_test.dart +++ /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. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('toMap / fromMap', () { - const cameraPosition = CameraPosition( - target: LatLng(10.0, 15.0), bearing: 0.5, tilt: 30.0, zoom: 1.5); - // Cast to to ensure that recreating from JSON, where - // type information will have likely been lost, still works. - final json = (cameraPosition.toMap() as Map) - .cast(); - final cameraPositionFromJson = CameraPosition.fromMap(json); - - expect(cameraPosition, cameraPositionFromJson); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/location_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/location_test.dart new file mode 100644 index 000000000000..80f696177dfd --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/location_test.dart @@ -0,0 +1,62 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LanLng constructor', () { + test('Maintains longitude precision if within acceptable range', () async { + const lat = -34.509981; + const lng = 150.792384; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(lng)); + }); + + test('Normalizes longitude that is below lower limit', () async { + const lat = -34.509981; + const lng = -270.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(90.0)); + }); + + test('Normalizes longitude that is above upper limit', () async { + const lat = -34.509981; + const lng = 270.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(-90.0)); + }); + + test('Includes longitude set to lower limit', () async { + const lat = -34.509981; + const lng = -180.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(-180.0)); + }); + + test('Normalizes longitude set to upper limit', () async { + const lat = -34.509981; + const lng = 180.0; + + final latLng = LatLng(lat, lng); + + expect(latLng.latitude, equals(lat)); + expect(latLng.longitude, equals(-180.0)); + }); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/maps_object_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/maps_object_test.dart deleted file mode 100644 index c2ca2bdda5b7..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/maps_object_test.dart +++ /dev/null @@ -1,45 +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:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/utils/maps_object.dart'; - -import 'test_maps_object.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - test('keyByMapsObjectId', () async { - const MapsObjectId id1 = MapsObjectId('1'); - const MapsObjectId id2 = MapsObjectId('2'); - const MapsObjectId id3 = MapsObjectId('3'); - const TestMapsObject object1 = TestMapsObject(id1); - const TestMapsObject object2 = TestMapsObject(id2, data: 2); - const TestMapsObject object3 = TestMapsObject(id3); - expect( - keyByMapsObjectId({object1, object2, object3}), - , TestMapsObject>{ - id1: object1, - id2: object2, - id3: object3, - }); - }); - - test('serializeMapsObjectSet', () async { - const MapsObjectId id1 = MapsObjectId('1'); - const MapsObjectId id2 = MapsObjectId('2'); - const MapsObjectId id3 = MapsObjectId('3'); - const TestMapsObject object1 = TestMapsObject(id1); - const TestMapsObject object2 = TestMapsObject(id2, data: 2); - const TestMapsObject object3 = TestMapsObject(id3); - expect( - serializeMapsObjectSet({object1, object2, object3}), - >[ - {'id': '1'}, - {'id': '2'}, - {'id': '3'} - ]); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/maps_object_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/maps_object_updates_test.dart deleted file mode 100644 index f09f70fd769e..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/maps_object_updates_test.dart +++ /dev/null @@ -1,162 +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:ui' show hashValues, hashList; - -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/maps_object.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/maps_object_updates.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/utils/maps_object.dart'; - -import 'test_maps_object.dart'; - -class TestMapsObjectUpdate extends MapsObjectUpdates { - TestMapsObjectUpdate.from( - Set previous, Set current) - : super.from(previous, current, objectName: 'testObject'); -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('tile overlay updates tests', () { - test('Correctly set toRemove, toAdd and toChange', () async { - const TestMapsObject to1 = - TestMapsObject(MapsObjectId('id1')); - const TestMapsObject to2 = - TestMapsObject(MapsObjectId('id2')); - const TestMapsObject to3 = - TestMapsObject(MapsObjectId('id3')); - const TestMapsObject to3Changed = - TestMapsObject(MapsObjectId('id3'), data: 2); - const TestMapsObject to4 = - TestMapsObject(MapsObjectId('id4')); - final Set previous = - Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TestMapsObjectUpdate updates = - TestMapsObjectUpdate.from(previous, current); - - final Set> toRemove = - Set.from(>[ - const MapsObjectId('id1') - ]); - expect(updates.objectIdsToRemove, toRemove); - - final Set toAdd = Set.from([to4]); - expect(updates.objectsToAdd, toAdd); - - final Set toChange = - Set.from([to3Changed]); - expect(updates.objectsToChange, toChange); - }); - - test('toJson', () async { - const TestMapsObject to1 = - TestMapsObject(MapsObjectId('id1')); - const TestMapsObject to2 = - TestMapsObject(MapsObjectId('id2')); - const TestMapsObject to3 = - TestMapsObject(MapsObjectId('id3')); - const TestMapsObject to3Changed = - TestMapsObject(MapsObjectId('id3'), data: 2); - const TestMapsObject to4 = - TestMapsObject(MapsObjectId('id4')); - final Set previous = - Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TestMapsObjectUpdate updates = - TestMapsObjectUpdate.from(previous, current); - - final Object json = updates.toJson(); - expect(json, { - 'testObjectsToAdd': serializeMapsObjectSet(updates.objectsToAdd), - 'testObjectsToChange': serializeMapsObjectSet(updates.objectsToChange), - 'testObjectIdsToRemove': updates.objectIdsToRemove - .map((MapsObjectId m) => m.value) - .toList() - }); - }); - - test('equality', () async { - const TestMapsObject to1 = - TestMapsObject(MapsObjectId('id1')); - const TestMapsObject to2 = - TestMapsObject(MapsObjectId('id2')); - const TestMapsObject to3 = - TestMapsObject(MapsObjectId('id3')); - const TestMapsObject to3Changed = - TestMapsObject(MapsObjectId('id3'), data: 2); - const TestMapsObject to4 = - TestMapsObject(MapsObjectId('id4')); - final Set previous = - Set.from([to1, to2, to3]); - final Set current1 = - Set.from([to2, to3Changed, to4]); - final Set current2 = - Set.from([to2, to3Changed, to4]); - final Set current3 = Set.from([to2, to4]); - final TestMapsObjectUpdate updates1 = - TestMapsObjectUpdate.from(previous, current1); - final TestMapsObjectUpdate updates2 = - TestMapsObjectUpdate.from(previous, current2); - final TestMapsObjectUpdate updates3 = - TestMapsObjectUpdate.from(previous, current3); - expect(updates1, updates2); - expect(updates1, isNot(updates3)); - }); - - test('hashCode', () async { - const TestMapsObject to1 = - TestMapsObject(MapsObjectId('id1')); - const TestMapsObject to2 = - TestMapsObject(MapsObjectId('id2')); - const TestMapsObject to3 = - TestMapsObject(MapsObjectId('id3')); - const TestMapsObject to3Changed = - TestMapsObject(MapsObjectId('id3'), data: 2); - const TestMapsObject to4 = - TestMapsObject(MapsObjectId('id4')); - final Set previous = - Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TestMapsObjectUpdate updates = - TestMapsObjectUpdate.from(previous, current); - expect( - updates.hashCode, - hashValues( - hashList(updates.objectsToAdd), - hashList(updates.objectIdsToRemove), - hashList(updates.objectsToChange))); - }); - - test('toString', () async { - const TestMapsObject to1 = - TestMapsObject(MapsObjectId('id1')); - const TestMapsObject to2 = - TestMapsObject(MapsObjectId('id2')); - const TestMapsObject to3 = - TestMapsObject(MapsObjectId('id3')); - const TestMapsObject to3Changed = - TestMapsObject(MapsObjectId('id3'), data: 2); - const TestMapsObject to4 = - TestMapsObject(MapsObjectId('id4')); - final Set previous = - Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TestMapsObjectUpdate updates = - TestMapsObjectUpdate.from(previous, current); - expect( - updates.toString(), - 'TestMapsObjectUpdate(add: ${updates.objectsToAdd}, ' - 'remove: ${updates.objectIdsToRemove}, ' - 'change: ${updates.objectsToChange})'); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart new file mode 100644 index 000000000000..c8f6fa527a95 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/marker_test.dart @@ -0,0 +1,167 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$Marker', () { + test('constructor defaults', () { + final Marker marker = Marker(markerId: MarkerId("ABC123")); + + expect(marker.alpha, equals(1.0)); + expect(marker.anchor, equals(const Offset(0.5, 1.0))); + expect(marker.consumeTapEvents, equals(false)); + expect(marker.draggable, equals(false)); + expect(marker.flat, equals(false)); + expect(marker.icon, equals(BitmapDescriptor.defaultMarker)); + expect(marker.infoWindow, equals(InfoWindow.noText)); + expect(marker.position, equals(const LatLng(0.0, 0.0))); + expect(marker.rotation, equals(0.0)); + expect(marker.visible, equals(true)); + expect(marker.zIndex, equals(0.0)); + expect(marker.onTap, equals(null)); + expect(marker.onDrag, equals(null)); + expect(marker.onDragStart, equals(null)); + expect(marker.onDragEnd, equals(null)); + }); + test('constructor alpha is >= 0.0 and <= 1.0', () { + final ValueSetter initWithAlpha = (double alpha) { + Marker(markerId: MarkerId("ABC123"), alpha: alpha); + }; + expect(() => initWithAlpha(-0.5), throwsAssertionError); + expect(() => initWithAlpha(0.0), isNot(throwsAssertionError)); + expect(() => initWithAlpha(0.5), isNot(throwsAssertionError)); + expect(() => initWithAlpha(1.0), isNot(throwsAssertionError)); + expect(() => initWithAlpha(100), throwsAssertionError); + }); + + test('toJson', () { + final BitmapDescriptor testDescriptor = + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan); + final Marker marker = Marker( + markerId: MarkerId("ABC123"), + alpha: 0.12345, + anchor: Offset(100, 100), + consumeTapEvents: true, + draggable: true, + flat: true, + icon: testDescriptor, + infoWindow: InfoWindow( + title: "Test title", + snippet: "Test snippet", + anchor: Offset(100, 200), + ), + position: LatLng(50, 50), + rotation: 100, + visible: false, + zIndex: 100, + onTap: () {}, + onDragStart: (LatLng latLng) {}, + onDrag: (LatLng latLng) {}, + onDragEnd: (LatLng latLng) {}, + ); + + final Map json = marker.toJson() as Map; + + expect(json, { + 'markerId': "ABC123", + 'alpha': 0.12345, + 'anchor': [100, 100], + 'consumeTapEvents': true, + 'draggable': true, + 'flat': true, + 'icon': testDescriptor.toJson(), + 'infoWindow': { + 'title': "Test title", + 'snippet': "Test snippet", + 'anchor': [100.0, 200.0], + }, + 'position': [50, 50], + 'rotation': 100.0, + 'visible': false, + 'zIndex': 100.0, + }); + }); + test('clone', () { + final Marker marker = Marker(markerId: MarkerId("ABC123")); + final Marker clone = marker.clone(); + + expect(identical(clone, marker), isFalse); + expect(clone, equals(marker)); + }); + test('copyWith', () { + final Marker marker = Marker(markerId: MarkerId("ABC123")); + + final BitmapDescriptor testDescriptor = + BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan); + final double testAlphaParam = 0.12345; + final Offset testAnchorParam = Offset(100, 100); + final bool testConsumeTapEventsParam = !marker.consumeTapEvents; + final bool testDraggableParam = !marker.draggable; + final bool testFlatParam = !marker.flat; + final BitmapDescriptor testIconParam = testDescriptor; + final InfoWindow testInfoWindowParam = InfoWindow(title: "Test"); + final LatLng testPositionParam = LatLng(100, 100); + final double testRotationParam = 100; + final bool testVisibleParam = !marker.visible; + final double testZIndexParam = 100; + final List log = []; + + final copy = marker.copyWith( + alphaParam: testAlphaParam, + anchorParam: testAnchorParam, + consumeTapEventsParam: testConsumeTapEventsParam, + draggableParam: testDraggableParam, + flatParam: testFlatParam, + iconParam: testIconParam, + infoWindowParam: testInfoWindowParam, + positionParam: testPositionParam, + rotationParam: testRotationParam, + visibleParam: testVisibleParam, + zIndexParam: testZIndexParam, + onTapParam: () { + log.add("onTapParam"); + }, + onDragStartParam: (LatLng latLng) { + log.add("onDragStartParam"); + }, + onDragParam: (LatLng latLng) { + log.add("onDragParam"); + }, + onDragEndParam: (LatLng latLng) { + log.add("onDragEndParam"); + }, + ); + + expect(copy.alpha, equals(testAlphaParam)); + expect(copy.anchor, equals(testAnchorParam)); + expect(copy.consumeTapEvents, equals(testConsumeTapEventsParam)); + expect(copy.draggable, equals(testDraggableParam)); + expect(copy.flat, equals(testFlatParam)); + expect(copy.icon, equals(testIconParam)); + expect(copy.infoWindow, equals(testInfoWindowParam)); + expect(copy.position, equals(testPositionParam)); + expect(copy.rotation, equals(testRotationParam)); + expect(copy.visible, equals(testVisibleParam)); + expect(copy.zIndex, equals(testZIndexParam)); + + copy.onTap!(); + expect(log, contains("onTapParam")); + + copy.onDragStart!(LatLng(0, 1)); + expect(log, contains("onDragStartParam")); + + copy.onDrag!(LatLng(0, 1)); + expect(log, contains("onDragParam")); + + copy.onDragEnd!(LatLng(0, 1)); + expect(log, contains("onDragEndParam")); + }); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/test_maps_object.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/test_maps_object.dart deleted file mode 100644 index b95ae50a8f08..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/test_maps_object.dart +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:ui' show hashValues; - -import 'package:flutter/rendering.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/maps_object.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/maps_object_updates.dart'; - -/// A trivial TestMapsObject implementation for testing updates with. -class TestMapsObject implements MapsObject { - const TestMapsObject(this.mapsId, {this.data = 1}); - - final MapsObjectId mapsId; - - final int data; - - @override - TestMapsObject clone() { - return TestMapsObject(mapsId, data: data); - } - - @override - Object toJson() { - return {'id': mapsId.value}; - } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - return other is TestMapsObject && - mapsId == other.mapsId && - data == other.data; - } - - @override - int get hashCode => hashValues(mapsId, data); -} - -class TestMapsObjectUpdate extends MapsObjectUpdates { - TestMapsObjectUpdate.from( - Set previous, Set current) - : super.from(previous, current, objectName: 'testObject'); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_overlay_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_overlay_test.dart deleted file mode 100644 index 3a4c34764ef7..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_overlay_test.dart +++ /dev/null @@ -1,143 +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:ui' show hashValues; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; - -class _TestTileProvider extends TileProvider { - @override - Future getTile(int x, int y, int? zoom) async { - return Tile(0, 0, null); - } -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('tile overlay id tests', () { - test('equality', () async { - const TileOverlayId id1 = TileOverlayId('1'); - const TileOverlayId id2 = TileOverlayId('1'); - const TileOverlayId id3 = TileOverlayId('2'); - expect(id1, id2); - expect(id1, isNot(id3)); - }); - - test('toString', () async { - const TileOverlayId id1 = TileOverlayId('1'); - expect(id1.toString(), 'TileOverlayId(1)'); - }); - }); - - group('tile overlay tests', () { - test('toJson returns correct format', () async { - const TileOverlay tileOverlay = TileOverlay( - tileOverlayId: TileOverlayId('id'), - fadeIn: false, - tileProvider: null, - transparency: 0.1, - zIndex: 1, - visible: false, - tileSize: 128); - final Object json = tileOverlay.toJson(); - expect(json, { - 'tileOverlayId': 'id', - 'fadeIn': false, - 'transparency': moreOrLessEquals(0.1), - 'zIndex': 1, - 'visible': false, - 'tileSize': 128, - }); - }); - - test('invalid transparency throws', () async { - expect( - () => TileOverlay( - tileOverlayId: const TileOverlayId('id1'), transparency: -0.1), - throwsAssertionError); - expect( - () => TileOverlay( - tileOverlayId: const TileOverlayId('id2'), transparency: 1.2), - throwsAssertionError); - }); - - test('equality', () async { - final TileProvider tileProvider = _TestTileProvider(); - final TileOverlay tileOverlay1 = TileOverlay( - tileOverlayId: TileOverlayId('id1'), - fadeIn: false, - tileProvider: tileProvider, - transparency: 0.1, - zIndex: 1, - visible: false, - tileSize: 128); - final TileOverlay tileOverlaySameValues = TileOverlay( - tileOverlayId: TileOverlayId('id1'), - fadeIn: false, - tileProvider: tileProvider, - transparency: 0.1, - zIndex: 1, - visible: false, - tileSize: 128); - final TileOverlay tileOverlayDifferentId = TileOverlay( - tileOverlayId: TileOverlayId('id2'), - fadeIn: false, - tileProvider: tileProvider, - transparency: 0.1, - zIndex: 1, - visible: false, - tileSize: 128); - final TileOverlay tileOverlayDifferentProvider = TileOverlay( - tileOverlayId: TileOverlayId('id1'), - fadeIn: false, - tileProvider: null, - transparency: 0.1, - zIndex: 1, - visible: false, - tileSize: 128); - expect(tileOverlay1, tileOverlaySameValues); - expect(tileOverlay1, isNot(tileOverlayDifferentId)); - expect(tileOverlay1, isNot(tileOverlayDifferentProvider)); - }); - - test('clone', () async { - final TileProvider tileProvider = _TestTileProvider(); - // Set non-default values for every parameter. - final TileOverlay tileOverlay = TileOverlay( - tileOverlayId: TileOverlayId('id1'), - fadeIn: false, - tileProvider: tileProvider, - transparency: 0.1, - zIndex: 1, - visible: false, - tileSize: 128); - expect(tileOverlay, tileOverlay.clone()); - }); - - test('hashCode', () async { - final TileProvider tileProvider = _TestTileProvider(); - const TileOverlayId id = TileOverlayId('id1'); - final TileOverlay tileOverlay = TileOverlay( - tileOverlayId: id, - fadeIn: false, - tileProvider: tileProvider, - transparency: 0.1, - zIndex: 1, - visible: false, - tileSize: 128); - expect( - tileOverlay.hashCode, - hashValues( - tileOverlay.tileOverlayId, - tileOverlay.fadeIn, - tileOverlay.tileProvider, - tileOverlay.transparency, - tileOverlay.zIndex, - tileOverlay.visible, - tileOverlay.tileSize)); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_overlay_updates_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_overlay_updates_test.dart deleted file mode 100644 index 05be14e1ba0b..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_overlay_updates_test.dart +++ /dev/null @@ -1,126 +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:ui' show hashValues, hashList; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/tile_overlay.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/tile_overlay_updates.dart'; -import 'package:google_maps_flutter_platform_interface/src/types/utils/tile_overlay.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('tile overlay updates tests', () { - test('Correctly set toRemove, toAdd and toChange', () async { - const TileOverlay to1 = TileOverlay(tileOverlayId: TileOverlayId('id1')); - const TileOverlay to2 = TileOverlay(tileOverlayId: TileOverlayId('id2')); - const TileOverlay to3 = TileOverlay(tileOverlayId: TileOverlayId('id3')); - const TileOverlay to3Changed = - TileOverlay(tileOverlayId: TileOverlayId('id3'), transparency: 0.5); - const TileOverlay to4 = TileOverlay(tileOverlayId: TileOverlayId('id4')); - final Set previous = Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TileOverlayUpdates updates = - TileOverlayUpdates.from(previous, current); - - final Set toRemove = - Set.from([const TileOverlayId('id1')]); - expect(updates.tileOverlayIdsToRemove, toRemove); - - final Set toAdd = Set.from([to4]); - expect(updates.tileOverlaysToAdd, toAdd); - - final Set toChange = Set.from([to3Changed]); - expect(updates.tileOverlaysToChange, toChange); - }); - - test('toJson', () async { - const TileOverlay to1 = TileOverlay(tileOverlayId: TileOverlayId('id1')); - const TileOverlay to2 = TileOverlay(tileOverlayId: TileOverlayId('id2')); - const TileOverlay to3 = TileOverlay(tileOverlayId: TileOverlayId('id3')); - const TileOverlay to3Changed = - TileOverlay(tileOverlayId: TileOverlayId('id3'), transparency: 0.5); - const TileOverlay to4 = TileOverlay(tileOverlayId: TileOverlayId('id4')); - final Set previous = Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TileOverlayUpdates updates = - TileOverlayUpdates.from(previous, current); - - final Object json = updates.toJson(); - expect(json, { - 'tileOverlaysToAdd': serializeTileOverlaySet(updates.tileOverlaysToAdd), - 'tileOverlaysToChange': - serializeTileOverlaySet(updates.tileOverlaysToChange), - 'tileOverlayIdsToRemove': updates.tileOverlayIdsToRemove - .map((TileOverlayId m) => m.value) - .toList() - }); - }); - - test('equality', () async { - const TileOverlay to1 = TileOverlay(tileOverlayId: TileOverlayId('id1')); - const TileOverlay to2 = TileOverlay(tileOverlayId: TileOverlayId('id2')); - const TileOverlay to3 = TileOverlay(tileOverlayId: TileOverlayId('id3')); - const TileOverlay to3Changed = - TileOverlay(tileOverlayId: TileOverlayId('id3'), transparency: 0.5); - const TileOverlay to4 = TileOverlay(tileOverlayId: TileOverlayId('id4')); - final Set previous = Set.from([to1, to2, to3]); - final Set current1 = - Set.from([to2, to3Changed, to4]); - final Set current2 = - Set.from([to2, to3Changed, to4]); - final Set current3 = Set.from([to2, to4]); - final TileOverlayUpdates updates1 = - TileOverlayUpdates.from(previous, current1); - final TileOverlayUpdates updates2 = - TileOverlayUpdates.from(previous, current2); - final TileOverlayUpdates updates3 = - TileOverlayUpdates.from(previous, current3); - expect(updates1, updates2); - expect(updates1, isNot(updates3)); - }); - - test('hashCode', () async { - const TileOverlay to1 = TileOverlay(tileOverlayId: TileOverlayId('id1')); - const TileOverlay to2 = TileOverlay(tileOverlayId: TileOverlayId('id2')); - const TileOverlay to3 = TileOverlay(tileOverlayId: TileOverlayId('id3')); - const TileOverlay to3Changed = - TileOverlay(tileOverlayId: TileOverlayId('id3'), transparency: 0.5); - const TileOverlay to4 = TileOverlay(tileOverlayId: TileOverlayId('id4')); - final Set previous = Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TileOverlayUpdates updates = - TileOverlayUpdates.from(previous, current); - expect( - updates.hashCode, - hashValues( - hashList(updates.tileOverlaysToAdd), - hashList(updates.tileOverlayIdsToRemove), - hashList(updates.tileOverlaysToChange))); - }); - - test('toString', () async { - const TileOverlay to1 = TileOverlay(tileOverlayId: TileOverlayId('id1')); - const TileOverlay to2 = TileOverlay(tileOverlayId: TileOverlayId('id2')); - const TileOverlay to3 = TileOverlay(tileOverlayId: TileOverlayId('id3')); - const TileOverlay to3Changed = - TileOverlay(tileOverlayId: TileOverlayId('id3'), transparency: 0.5); - const TileOverlay to4 = TileOverlay(tileOverlayId: TileOverlayId('id4')); - final Set previous = Set.from([to1, to2, to3]); - final Set current = - Set.from([to2, to3Changed, to4]); - final TileOverlayUpdates updates = - TileOverlayUpdates.from(previous, current); - expect( - updates.toString(), - 'TileOverlayUpdates(add: ${updates.tileOverlaysToAdd}, ' - 'remove: ${updates.tileOverlayIdsToRemove}, ' - 'change: ${updates.tileOverlaysToChange})'); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_test.dart b/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_test.dart deleted file mode 100644 index 653958474185..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/test/types/tile_test.dart +++ /dev/null @@ -1,34 +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:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('tile tests', () { - test('toJson returns correct format', () async { - final Uint8List data = Uint8List.fromList([0, 1]); - final Tile tile = Tile(100, 200, data); - final Object json = tile.toJson(); - expect(json, { - 'width': 100, - 'height': 200, - 'data': data, - }); - }); - - test('toJson handles null data', () async { - const Tile tile = Tile(0, 0, null); - final Object json = tile.toJson(); - expect(json, { - 'width': 0, - 'height': 0, - }); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md deleted file mode 100644 index 36a4271cb95d..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/CHANGELOG.md +++ /dev/null @@ -1,86 +0,0 @@ -## 0.3.0+2 - -* Document `liteModeEnabled` is not available on the web. [#83737](https://github.com/flutter/flutter/issues/83737). - -## 0.3.0+1 - -* Change sizing code of `GoogleMap` widget's `HtmlElementView` so it works well when slotted. - -## 0.3.0 - -* Migrate package to null-safety. -* **Breaking changes:** - * The property `icon` of a `Marker` cannot be `null`. Defaults to `BitmapDescriptor.defaultMarker` - * The property `initialCameraPosition` of a `GoogleMapController` can't be `null`. It is also marked as `required`. - * The parameter `creationId` of the `buildView` method cannot be `null` (this should be handled internally for users of the plugin) - * Most of the Controller methods can't be called after `remove`/`dispose`. Calling these methods now will throw an Assertion error. Before it'd be a no-op, or a null-pointer exception. - -## 0.2.1 - -* Move integration tests to `example`. -* Tweak pubspec dependencies for main package. - -## 0.2.0 - -* Make this plugin compatible with the rest of null-safe plugins. -* Noop tile overlays methods, so they don't crash on web. - -**NOTE**: This plugin is **not** null-safe yet! - -## 0.1.2 - -* Update min Flutter SDK to 1.20.0. - -## 0.1.1 - -* Auto-reverse holes if they're the same direction as the polygon. [Issue](https://github.com/flutter/flutter/issues/74096). - -## 0.1.0+10 - -* Update `package:google_maps_flutter_platform_interface` to `^1.1.0`. -* Add support for Polygon Holes. - -## 0.1.0+9 - -* Update Flutter SDK constraint. - -## 0.1.0+8 - -* Update `package:google_maps_flutter_platform_interface` to `^1.0.5`. -* Add support for `fromBitmap` BitmapDescriptors. [Issue](https://github.com/flutter/flutter/issues/66622). - -## 0.1.0+7 - -* Substitute `undefined_prefixed_name: ignore` analyzer setting by a `dart:ui` shim with conditional exports. [Issue](https://github.com/flutter/flutter/issues/69309). - -## 0.1.0+6 - -* Ensure a single `InfoWindow` is shown at a time. [Issue](https://github.com/flutter/flutter/issues/67380). - -## 0.1.0+5 - -* Update `package:google_maps` to `^3.4.5`. -* Fix `GoogleMapController.getLatLng()`. [Issue](https://github.com/flutter/flutter/issues/67606). -* Make `InfoWindow` contents clickable so `onTap` works as advertised. [Issue](https://github.com/flutter/flutter/issues/67289). -* Fix `InfoWindow` snippets when converting initial markers. [Issue](https://github.com/flutter/flutter/issues/67854). - -## 0.1.0+4 - -* Update `package:sanitize_html` to `^1.4.1` to prevent [a crash](https://github.com/flutter/flutter/issues/67854) when InfoWindow title/snippet have links. - -## 0.1.0+3 - -* Fix crash when converting initial polylines and polygons. [Issue](https://github.com/flutter/flutter/issues/65152). -* Correctly convert Colors when rendering polylines, polygons and circles. [Issue](https://github.com/flutter/flutter/issues/67032). - -## 0.1.0+2 - -* Fix crash when converting Markers with icon explicitly set to null. [Issue](https://github.com/flutter/flutter/issues/64938). - -## 0.1.0+1 - -* Port e2e tests to use the new integration_test package. - -## 0.1.0 - -* First open-source version diff --git a/packages/google_maps_flutter/google_maps_flutter_web/README.md b/packages/google_maps_flutter/google_maps_flutter_web/README.md deleted file mode 100644 index cfd5f6d8271e..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# google_maps_flutter_web - -This is an implementation of the [google_maps_flutter](https://pub.dev/packages/google_maps_flutter) plugin for web. Behind the scenes, it uses a14n's [google_maps](https://pub.dev/packages/google_maps) dart JS interop layer. - -## Usage - -### 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 -``` - -### Modify web/index.html - -Get an API Key for Google Maps JavaScript API. Get started [here](https://developers.google.com/maps/documentation/javascript/get-api-key). - -Modify the `` tag of your `web/index.html` to load the Google Maps JavaScript API, like so: - -```html - - - - - - -``` - -Now you should be able to use the Google Maps plugin normally. - -## Limitations of the web version - -The following map options are not available in web, because the map doesn't rotate there: - -* `compassEnabled` -* `rotateGesturesEnabled` -* `tiltGesturesEnabled` - -There's no "Map Toolbar" in web, so the `mapToolbarEnabled` option is unused. - -There's no "My Location" widget in web ([tracking issue](https://github.com/flutter/flutter/issues/64073)), so the following options are ignored, for now: - -* `myLocationButtonEnabled` -* `myLocationEnabled` - -There's no `defaultMarkerWithHue` in web. If you need colored pins/markers, you may need to use your own asset images. - -Indoor and building layers are still not available on the web. Traffic is. - -Only Android supports "[Lite Mode](https://developers.google.com/maps/documentation/android-sdk/lite)", so the `liteModeEnabled` constructor argument can't be set to `true` on web apps. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/README.md b/packages/google_maps_flutter/google_maps_flutter_web/example/README.md deleted file mode 100644 index 582288a561a4..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Testing - -This package utilizes the `integration_test` package to run its tests in a web browser. - -See [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) for more info. - -## 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` - -## 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. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/build.yaml b/packages/google_maps_flutter/google_maps_flutter_web/example/build.yaml deleted file mode 100644 index db3104bb04c6..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/build.yaml +++ /dev/null @@ -1,6 +0,0 @@ -targets: - $default: - sources: - - integration_test/*.dart - - lib/$lib$ - - $package$ diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart deleted file mode 100644 index 1d33eea4c7f3..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.dart +++ /dev/null @@ -1,665 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:html' as html; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'google_maps_controller_test.mocks.dart'; - -// This value is used when comparing long~num, like -// LatLng values. -const _acceptableDelta = 0.0000000001; - -@GenerateMocks([], customMocks: [ - MockSpec(returnNullOnMissingStub: true), - MockSpec(returnNullOnMissingStub: true), - MockSpec(returnNullOnMissingStub: true), - MockSpec(returnNullOnMissingStub: true), -]) - -/// Test Google Map Controller -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('GoogleMapController', () { - final int mapId = 33930; - late GoogleMapController controller; - late StreamController stream; - - // Creates a controller with the default mapId and stream controller, and any `options` needed. - GoogleMapController _createController({ - CameraPosition initialCameraPosition = - const CameraPosition(target: LatLng(0, 0)), - Set markers = const {}, - Set polygons = const {}, - Set polylines = const {}, - Set circles = const {}, - Map options = const {}, - }) { - return GoogleMapController( - mapId: mapId, - streamController: stream, - initialCameraPosition: initialCameraPosition, - markers: markers, - polygons: polygons, - polylines: polylines, - circles: circles, - mapOptions: options, - ); - } - - setUp(() { - stream = StreamController.broadcast(); - }); - - group('construct/dispose', () { - setUp(() { - controller = _createController(); - }); - - testWidgets('constructor creates widget', (WidgetTester tester) async { - expect(controller.widget, isNotNull); - expect(controller.widget, isA()); - expect((controller.widget as HtmlElementView).viewType, - endsWith('$mapId')); - }); - - testWidgets('widget is cached when reused', (WidgetTester tester) async { - final first = controller.widget; - final again = controller.widget; - expect(identical(first, again), isTrue); - }); - - group('dispose', () { - testWidgets('closes the stream and removes the widget', - (WidgetTester tester) async { - controller.dispose(); - - expect(stream.isClosed, isTrue); - expect(controller.widget, isNull); - }); - - testWidgets('cannot call getVisibleRegion after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() async { - await controller.getVisibleRegion(); - }, throwsAssertionError); - }); - - testWidgets('cannot call getScreenCoordinate after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() async { - await controller.getScreenCoordinate( - LatLng(43.3072465, -5.6918241), - ); - }, throwsAssertionError); - }); - - testWidgets('cannot call getLatLng after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() async { - await controller.getLatLng( - ScreenCoordinate(x: 640, y: 480), - ); - }, throwsAssertionError); - }); - - testWidgets('cannot call moveCamera after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() async { - await controller.moveCamera(CameraUpdate.zoomIn()); - }, throwsAssertionError); - }); - - testWidgets('cannot call getZoomLevel after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() async { - await controller.getZoomLevel(); - }, throwsAssertionError); - }); - - testWidgets('cannot updateCircles after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() { - controller.updateCircles(CircleUpdates.from({}, {})); - }, throwsAssertionError); - }); - - testWidgets('cannot updatePolygons after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() { - controller.updatePolygons(PolygonUpdates.from({}, {})); - }, throwsAssertionError); - }); - - testWidgets('cannot updatePolylines after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() { - controller.updatePolylines(PolylineUpdates.from({}, {})); - }, throwsAssertionError); - }); - - testWidgets('cannot updateMarkers after dispose', - (WidgetTester tester) async { - controller.dispose(); - - expect(() { - controller.updateMarkers(MarkerUpdates.from({}, {})); - }, throwsAssertionError); - - expect(() { - controller.showInfoWindow(MarkerId('any')); - }, throwsAssertionError); - - expect(() { - controller.hideInfoWindow(MarkerId('any')); - }, throwsAssertionError); - }); - - testWidgets('isInfoWindowShown defaults to false', - (WidgetTester tester) async { - controller.dispose(); - - expect(controller.isInfoWindowShown(MarkerId('any')), false); - }); - }); - }); - - group('init', () { - late MockCirclesController circles; - late MockMarkersController markers; - late MockPolygonsController polygons; - late MockPolylinesController polylines; - late gmaps.GMap map; - - setUp(() { - circles = MockCirclesController(); - markers = MockMarkersController(); - polygons = MockPolygonsController(); - polylines = MockPolylinesController(); - map = gmaps.GMap(html.DivElement()); - }); - - testWidgets('listens to map events', (WidgetTester tester) async { - controller = _createController(); - controller.debugSetOverrides( - createMap: (_, __) => map, - circles: circles, - markers: markers, - polygons: polygons, - polylines: polylines, - ); - - controller.init(); - - // Trigger events on the map, and verify they've been broadcast to the stream - final capturedEvents = stream.stream.take(5); - - gmaps.Event.trigger( - map, 'click', [gmaps.MapMouseEvent()..latLng = gmaps.LatLng(0, 0)]); - gmaps.Event.trigger(map, 'rightclick', - [gmaps.MapMouseEvent()..latLng = gmaps.LatLng(0, 0)]); - gmaps.Event.trigger(map, 'bounds_changed', []); // Causes 2 events - gmaps.Event.trigger(map, 'idle', []); - - final events = await capturedEvents.toList(); - - expect(events[0], isA()); - expect(events[1], isA()); - expect(events[2], isA()); - expect(events[3], isA()); - expect(events[4], isA()); - }); - - testWidgets('binds geometry controllers to map\'s', - (WidgetTester tester) async { - controller = _createController(); - controller.debugSetOverrides( - createMap: (_, __) => map, - circles: circles, - markers: markers, - polygons: polygons, - polylines: polylines, - ); - - controller.init(); - - verify(circles.bindToMap(mapId, map)); - verify(markers.bindToMap(mapId, map)); - verify(polygons.bindToMap(mapId, map)); - verify(polylines.bindToMap(mapId, map)); - }); - - testWidgets('renders initial geometry', (WidgetTester tester) async { - controller = _createController(circles: { - Circle(circleId: CircleId('circle-1')) - }, markers: { - Marker( - markerId: MarkerId('marker-1'), - infoWindow: InfoWindow( - title: 'title for test', snippet: 'snippet for test')) - }, polygons: { - Polygon(polygonId: PolygonId('polygon-1'), points: [ - LatLng(43.355114, -5.851333), - LatLng(43.354797, -5.851860), - LatLng(43.354469, -5.851318), - LatLng(43.354762, -5.850824), - ]), - Polygon( - polygonId: PolygonId('polygon-2-with-holes'), - points: [ - LatLng(43.355114, -5.851333), - LatLng(43.354797, -5.851860), - LatLng(43.354469, -5.851318), - LatLng(43.354762, -5.850824), - ], - holes: [ - [ - LatLng(41.354797, -6.851860), - LatLng(41.354469, -6.851318), - LatLng(41.354762, -6.850824), - ] - ], - ), - }, polylines: { - Polyline(polylineId: PolylineId('polyline-1'), points: [ - LatLng(43.355114, -5.851333), - LatLng(43.354797, -5.851860), - LatLng(43.354469, -5.851318), - LatLng(43.354762, -5.850824), - ]) - }); - - controller.debugSetOverrides( - circles: circles, - markers: markers, - polygons: polygons, - polylines: polylines, - ); - - controller.init(); - - final capturedCircles = - verify(circles.addCircles(captureAny)).captured[0] as Set; - final capturedMarkers = - verify(markers.addMarkers(captureAny)).captured[0] as Set; - final capturedPolygons = verify(polygons.addPolygons(captureAny)) - .captured[0] as Set; - final capturedPolylines = verify(polylines.addPolylines(captureAny)) - .captured[0] as Set; - - expect(capturedCircles.first.circleId.value, 'circle-1'); - expect(capturedMarkers.first.markerId.value, 'marker-1'); - expect(capturedMarkers.first.infoWindow.snippet, 'snippet for test'); - expect(capturedMarkers.first.infoWindow.title, 'title for test'); - expect(capturedPolygons.first.polygonId.value, 'polygon-1'); - expect(capturedPolygons.elementAt(1).polygonId.value, - 'polygon-2-with-holes'); - expect(capturedPolygons.elementAt(1).holes, isNot(null)); - expect(capturedPolylines.first.polylineId.value, 'polyline-1'); - }); - - testWidgets('empty infoWindow does not create InfoWindow instance.', - (WidgetTester tester) async { - controller = _createController(markers: { - Marker(markerId: MarkerId('marker-1')), - }); - - controller.debugSetOverrides( - markers: markers, - ); - - controller.init(); - - final capturedMarkers = - verify(markers.addMarkers(captureAny)).captured[0] as Set; - - expect(capturedMarkers.first.infoWindow, InfoWindow.noText); - }); - - group('Initialization options', () { - gmaps.MapOptions? capturedOptions; - setUp(() { - capturedOptions = null; - }); - testWidgets('translates initial options', (WidgetTester tester) async { - controller = _createController(options: { - 'mapType': 2, - 'zoomControlsEnabled': true, - }); - controller.debugSetOverrides(createMap: (_, options) { - capturedOptions = options; - return map; - }); - - controller.init(); - - expect(capturedOptions, isNotNull); - expect(capturedOptions!.mapTypeId, gmaps.MapTypeId.SATELLITE); - expect(capturedOptions!.zoomControl, true); - expect(capturedOptions!.gestureHandling, 'auto', - reason: - 'by default the map handles zoom/pan gestures internally'); - }); - - testWidgets('disables gestureHandling with scrollGesturesEnabled false', - (WidgetTester tester) async { - controller = _createController(options: { - 'scrollGesturesEnabled': false, - }); - controller.debugSetOverrides(createMap: (_, options) { - capturedOptions = options; - return map; - }); - - controller.init(); - - expect(capturedOptions, isNotNull); - expect(capturedOptions!.gestureHandling, 'none', - reason: - 'disabling scroll gestures disables all gesture handling'); - }); - - testWidgets('disables gestureHandling with zoomGesturesEnabled false', - (WidgetTester tester) async { - controller = _createController(options: { - 'zoomGesturesEnabled': false, - }); - controller.debugSetOverrides(createMap: (_, options) { - capturedOptions = options; - return map; - }); - - controller.init(); - - expect(capturedOptions, isNotNull); - expect(capturedOptions!.gestureHandling, 'none', - reason: - 'disabling scroll gestures disables all gesture handling'); - }); - - testWidgets('sets initial position when passed', - (WidgetTester tester) async { - controller = _createController( - initialCameraPosition: CameraPosition( - target: LatLng(43.308, -5.6910), - zoom: 12, - bearing: 0, - tilt: 0, - ), - ); - - controller.debugSetOverrides(createMap: (_, options) { - capturedOptions = options; - return map; - }); - - controller.init(); - - expect(capturedOptions, isNotNull); - expect(capturedOptions!.zoom, 12); - expect(capturedOptions!.center, isNotNull); - }); - }); - - group('Traffic Layer', () { - testWidgets('by default is disabled', (WidgetTester tester) async { - controller = _createController(); - controller.init(); - expect(controller.trafficLayer, isNull); - }); - - testWidgets('initializes with traffic layer', - (WidgetTester tester) async { - controller = _createController(options: { - 'trafficEnabled': true, - }); - controller.debugSetOverrides(createMap: (_, __) => map); - controller.init(); - expect(controller.trafficLayer, isNotNull); - }); - }); - }); - - // These are the methods that are delegated to the gmaps.GMap object, that we can mock... - group('Map control methods', () { - late gmaps.GMap map; - - setUp(() { - map = gmaps.GMap( - html.DivElement(), - gmaps.MapOptions() - ..zoom = 10 - ..center = gmaps.LatLng(0, 0), - ); - controller = _createController(); - controller.debugSetOverrides(createMap: (_, __) => map); - controller.init(); - }); - - group('updateRawOptions', () { - testWidgets('can update `options`', (WidgetTester tester) async { - controller.updateRawOptions({ - 'mapType': 2, - }); - - expect(map.mapTypeId, gmaps.MapTypeId.SATELLITE); - }); - - testWidgets('can turn on/off traffic', (WidgetTester tester) async { - expect(controller.trafficLayer, isNull); - - controller.updateRawOptions({ - 'trafficEnabled': true, - }); - - expect(controller.trafficLayer, isNotNull); - - controller.updateRawOptions({ - 'trafficEnabled': false, - }); - - expect(controller.trafficLayer, isNull); - }); - }); - - group('viewport getters', () { - testWidgets('getVisibleRegion', (WidgetTester tester) async { - final gmCenter = map.center!; - final center = - LatLng(gmCenter.lat.toDouble(), gmCenter.lng.toDouble()); - - final bounds = await controller.getVisibleRegion(); - - expect(bounds.contains(center), isTrue, - reason: - 'The computed visible region must contain the center of the created map.'); - }); - - testWidgets('getZoomLevel', (WidgetTester tester) async { - expect(await controller.getZoomLevel(), map.zoom); - }); - }); - - group('moveCamera', () { - testWidgets('newLatLngZoom', (WidgetTester tester) async { - await (controller - .moveCamera(CameraUpdate.newLatLngZoom(LatLng(19, 26), 12))); - - final gmCenter = map.center!; - - expect(map.zoom, 12); - expect(gmCenter.lat, closeTo(19, _acceptableDelta)); - expect(gmCenter.lng, closeTo(26, _acceptableDelta)); - }); - }); - - group('map.projection methods', () { - // These are too much for dart mockito, can't mock: - // map.projection.method() (in Javascript ;) ) - - // Caused https://github.com/flutter/flutter/issues/67606 - }); - }); - - // These are the methods that get forwarded to other controllers, so we just verify calls. - group('Pass-through methods', () { - setUp(() { - controller = _createController(); - }); - - testWidgets('updateCircles', (WidgetTester tester) async { - final mock = MockCirclesController(); - controller.debugSetOverrides(circles: mock); - - final previous = { - Circle(circleId: CircleId('to-be-updated')), - Circle(circleId: CircleId('to-be-removed')), - }; - - final current = { - Circle(circleId: CircleId('to-be-updated'), visible: false), - Circle(circleId: CircleId('to-be-added')), - }; - - controller.updateCircles(CircleUpdates.from(previous, current)); - - verify(mock.removeCircles({ - CircleId('to-be-removed'), - })); - verify(mock.addCircles({ - Circle(circleId: CircleId('to-be-added')), - })); - verify(mock.changeCircles({ - Circle(circleId: CircleId('to-be-updated'), visible: false), - })); - }); - - testWidgets('updateMarkers', (WidgetTester tester) async { - final mock = MockMarkersController(); - controller.debugSetOverrides(markers: mock); - - final previous = { - Marker(markerId: MarkerId('to-be-updated')), - Marker(markerId: MarkerId('to-be-removed')), - }; - - final current = { - Marker(markerId: MarkerId('to-be-updated'), visible: false), - Marker(markerId: MarkerId('to-be-added')), - }; - - controller.updateMarkers(MarkerUpdates.from(previous, current)); - - verify(mock.removeMarkers({ - MarkerId('to-be-removed'), - })); - verify(mock.addMarkers({ - Marker(markerId: MarkerId('to-be-added')), - })); - verify(mock.changeMarkers({ - Marker(markerId: MarkerId('to-be-updated'), visible: false), - })); - }); - - testWidgets('updatePolygons', (WidgetTester tester) async { - final mock = MockPolygonsController(); - controller.debugSetOverrides(polygons: mock); - - final previous = { - Polygon(polygonId: PolygonId('to-be-updated')), - Polygon(polygonId: PolygonId('to-be-removed')), - }; - - final current = { - Polygon(polygonId: PolygonId('to-be-updated'), visible: false), - Polygon(polygonId: PolygonId('to-be-added')), - }; - - controller.updatePolygons(PolygonUpdates.from(previous, current)); - - verify(mock.removePolygons({ - PolygonId('to-be-removed'), - })); - verify(mock.addPolygons({ - Polygon(polygonId: PolygonId('to-be-added')), - })); - verify(mock.changePolygons({ - Polygon(polygonId: PolygonId('to-be-updated'), visible: false), - })); - }); - - testWidgets('updatePolylines', (WidgetTester tester) async { - final mock = MockPolylinesController(); - controller.debugSetOverrides(polylines: mock); - - final previous = { - Polyline(polylineId: PolylineId('to-be-updated')), - Polyline(polylineId: PolylineId('to-be-removed')), - }; - - final current = { - Polyline(polylineId: PolylineId('to-be-updated'), visible: false), - Polyline(polylineId: PolylineId('to-be-added')), - }; - - controller.updatePolylines(PolylineUpdates.from(previous, current)); - - verify(mock.removePolylines({ - PolylineId('to-be-removed'), - })); - verify(mock.addPolylines({ - Polyline(polylineId: PolylineId('to-be-added')), - })); - verify(mock.changePolylines({ - Polyline(polylineId: PolylineId('to-be-updated'), visible: false), - })); - }); - - testWidgets('infoWindow visibility', (WidgetTester tester) async { - final mock = MockMarkersController(); - final markerId = MarkerId('marker-with-infowindow'); - when(mock.isInfoWindowShown(markerId)).thenReturn(true); - controller.debugSetOverrides(markers: mock); - - controller.showInfoWindow(markerId); - - verify(mock.showMarkerInfoWindow(markerId)); - - controller.hideInfoWindow(markerId); - - verify(mock.hideMarkerInfoWindow(markerId)); - - controller.isInfoWindowShown(markerId); - - verify(mock.isInfoWindowShown(markerId)); - }); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart deleted file mode 100644 index 47933285b208..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_controller_test.mocks.dart +++ /dev/null @@ -1,202 +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. - -// Mocks generated by Mockito 5.0.2 from annotations -// in google_maps_flutter_web_integration_tests/integration_test/google_maps_controller_test.dart. -// Do not manually edit this file. - -import 'package:google_maps/src/generated/google_maps_core.js.g.dart' as _i2; -import 'package:google_maps_flutter_platform_interface/src/types/circle.dart' - as _i4; -import 'package:google_maps_flutter_platform_interface/src/types/marker.dart' - as _i7; -import 'package:google_maps_flutter_platform_interface/src/types/polygon.dart' - as _i5; -import 'package:google_maps_flutter_platform_interface/src/types/polyline.dart' - as _i6; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: comment_references -// ignore_for_file: unnecessary_parenthesis - -class _FakeGMap extends _i1.Fake implements _i2.GMap {} - -/// A class which mocks [CirclesController]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockCirclesController extends _i1.Mock implements _i3.CirclesController { - @override - Map<_i4.CircleId, _i3.CircleController> get circles => - (super.noSuchMethod(Invocation.getter(#circles), - returnValue: <_i4.CircleId, _i3.CircleController>{}) - as Map<_i4.CircleId, _i3.CircleController>); - @override - _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); - @override - set googleMap(_i2.GMap? _googleMap) => - super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), - returnValueForMissingStub: null); - @override - int get mapId => - (super.noSuchMethod(Invocation.getter(#mapId), returnValue: 0) as int); - @override - set mapId(int? _mapId) => - super.noSuchMethod(Invocation.setter(#mapId, _mapId), - returnValueForMissingStub: null); - @override - void addCircles(Set<_i4.Circle>? circlesToAdd) => - super.noSuchMethod(Invocation.method(#addCircles, [circlesToAdd]), - returnValueForMissingStub: null); - @override - void changeCircles(Set<_i4.Circle>? circlesToChange) => - super.noSuchMethod(Invocation.method(#changeCircles, [circlesToChange]), - returnValueForMissingStub: null); - @override - void removeCircles(Set<_i4.CircleId>? circleIdsToRemove) => - super.noSuchMethod(Invocation.method(#removeCircles, [circleIdsToRemove]), - returnValueForMissingStub: null); - @override - void bindToMap(int? mapId, _i2.GMap? googleMap) => - super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), - returnValueForMissingStub: null); -} - -/// A class which mocks [PolygonsController]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPolygonsController extends _i1.Mock - implements _i3.PolygonsController { - @override - Map<_i5.PolygonId, _i3.PolygonController> get polygons => - (super.noSuchMethod(Invocation.getter(#polygons), - returnValue: <_i5.PolygonId, _i3.PolygonController>{}) - as Map<_i5.PolygonId, _i3.PolygonController>); - @override - _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); - @override - set googleMap(_i2.GMap? _googleMap) => - super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), - returnValueForMissingStub: null); - @override - int get mapId => - (super.noSuchMethod(Invocation.getter(#mapId), returnValue: 0) as int); - @override - set mapId(int? _mapId) => - super.noSuchMethod(Invocation.setter(#mapId, _mapId), - returnValueForMissingStub: null); - @override - void addPolygons(Set<_i5.Polygon>? polygonsToAdd) => - super.noSuchMethod(Invocation.method(#addPolygons, [polygonsToAdd]), - returnValueForMissingStub: null); - @override - void changePolygons(Set<_i5.Polygon>? polygonsToChange) => - super.noSuchMethod(Invocation.method(#changePolygons, [polygonsToChange]), - returnValueForMissingStub: null); - @override - void removePolygons(Set<_i5.PolygonId>? polygonIdsToRemove) => super - .noSuchMethod(Invocation.method(#removePolygons, [polygonIdsToRemove]), - returnValueForMissingStub: null); - @override - void bindToMap(int? mapId, _i2.GMap? googleMap) => - super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), - returnValueForMissingStub: null); -} - -/// A class which mocks [PolylinesController]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockPolylinesController extends _i1.Mock - implements _i3.PolylinesController { - @override - Map<_i6.PolylineId, _i3.PolylineController> get lines => - (super.noSuchMethod(Invocation.getter(#lines), - returnValue: <_i6.PolylineId, _i3.PolylineController>{}) - as Map<_i6.PolylineId, _i3.PolylineController>); - @override - _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); - @override - set googleMap(_i2.GMap? _googleMap) => - super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), - returnValueForMissingStub: null); - @override - int get mapId => - (super.noSuchMethod(Invocation.getter(#mapId), returnValue: 0) as int); - @override - set mapId(int? _mapId) => - super.noSuchMethod(Invocation.setter(#mapId, _mapId), - returnValueForMissingStub: null); - @override - void addPolylines(Set<_i6.Polyline>? polylinesToAdd) => - super.noSuchMethod(Invocation.method(#addPolylines, [polylinesToAdd]), - returnValueForMissingStub: null); - @override - void changePolylines(Set<_i6.Polyline>? polylinesToChange) => super - .noSuchMethod(Invocation.method(#changePolylines, [polylinesToChange]), - returnValueForMissingStub: null); - @override - void removePolylines(Set<_i6.PolylineId>? polylineIdsToRemove) => super - .noSuchMethod(Invocation.method(#removePolylines, [polylineIdsToRemove]), - returnValueForMissingStub: null); - @override - void bindToMap(int? mapId, _i2.GMap? googleMap) => - super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), - returnValueForMissingStub: null); -} - -/// A class which mocks [MarkersController]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockMarkersController extends _i1.Mock implements _i3.MarkersController { - @override - Map<_i7.MarkerId, _i3.MarkerController> get markers => - (super.noSuchMethod(Invocation.getter(#markers), - returnValue: <_i7.MarkerId, _i3.MarkerController>{}) - as Map<_i7.MarkerId, _i3.MarkerController>); - @override - _i2.GMap get googleMap => (super.noSuchMethod(Invocation.getter(#googleMap), - returnValue: _FakeGMap()) as _i2.GMap); - @override - set googleMap(_i2.GMap? _googleMap) => - super.noSuchMethod(Invocation.setter(#googleMap, _googleMap), - returnValueForMissingStub: null); - @override - int get mapId => - (super.noSuchMethod(Invocation.getter(#mapId), returnValue: 0) as int); - @override - set mapId(int? _mapId) => - super.noSuchMethod(Invocation.setter(#mapId, _mapId), - returnValueForMissingStub: null); - @override - void addMarkers(Set<_i7.Marker>? markersToAdd) => - super.noSuchMethod(Invocation.method(#addMarkers, [markersToAdd]), - returnValueForMissingStub: null); - @override - void changeMarkers(Set<_i7.Marker>? markersToChange) => - super.noSuchMethod(Invocation.method(#changeMarkers, [markersToChange]), - returnValueForMissingStub: null); - @override - void removeMarkers(Set<_i7.MarkerId>? markerIdsToRemove) => - super.noSuchMethod(Invocation.method(#removeMarkers, [markerIdsToRemove]), - returnValueForMissingStub: null); - @override - void showMarkerInfoWindow(_i7.MarkerId? markerId) => - super.noSuchMethod(Invocation.method(#showMarkerInfoWindow, [markerId]), - returnValueForMissingStub: null); - @override - void hideMarkerInfoWindow(_i7.MarkerId? markerId) => - super.noSuchMethod(Invocation.method(#hideMarkerInfoWindow, [markerId]), - returnValueForMissingStub: null); - @override - bool isInfoWindowShown(_i7.MarkerId? markerId) => - (super.noSuchMethod(Invocation.method(#isInfoWindowShown, [markerId]), - returnValue: false) as bool); - @override - void bindToMap(int? mapId, _i2.GMap? googleMap) => - super.noSuchMethod(Invocation.method(#bindToMap, [mapId, googleMap]), - returnValueForMissingStub: null); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart deleted file mode 100644 index 2de431a5445e..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.dart +++ /dev/null @@ -1,435 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:js_util' show getProperty; - -import 'package:integration_test/integration_test.dart'; -import 'package:flutter/widgets.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; - -import 'google_maps_plugin_test.mocks.dart'; - -@GenerateMocks([], customMocks: [ - MockSpec(returnNullOnMissingStub: true), -]) - -/// Test GoogleMapsPlugin -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('GoogleMapsPlugin', () { - late MockGoogleMapController controller; - late GoogleMapsPlugin plugin; - int? reportedMapId; - - void onPlatformViewCreated(int id) { - reportedMapId = id; - } - - setUp(() { - controller = MockGoogleMapController(); - plugin = GoogleMapsPlugin(); - reportedMapId = null; - }); - - group('init/dispose', () { - group('before buildWidget', () { - testWidgets('init throws assertion', (WidgetTester tester) async { - expect(() => plugin.init(0), throwsAssertionError); - }); - }); - - group('after buildWidget', () { - setUp(() { - plugin.debugSetMapById({0: controller}); - }); - - testWidgets('init initializes controller', (WidgetTester tester) async { - await plugin.init(0); - - verify(controller.init()); - }); - - testWidgets('cannot call methods after dispose', - (WidgetTester tester) async { - plugin.dispose(mapId: 0); - - verify(controller.dispose()); - expect( - () => plugin.init(0), - throwsAssertionError, - reason: 'Method calls should fail after dispose.', - ); - }); - }); - }); - - group('buildView', () { - final testMapId = 33930; - final initialCameraPosition = CameraPosition(target: LatLng(0, 0)); - - testWidgets( - 'returns an HtmlElementView and caches the controller for later', - (WidgetTester tester) async { - final Map cache = {}; - plugin.debugSetMapById(cache); - - final Widget widget = plugin.buildView( - testMapId, - onPlatformViewCreated, - initialCameraPosition: initialCameraPosition, - ); - - expect(widget, isA()); - expect( - (widget as HtmlElementView).viewType, - contains('$testMapId'), - reason: - 'view type should contain the mapId passed when creating the map.', - ); - expect( - reportedMapId, - testMapId, - reason: 'Should call onPlatformViewCreated with the mapId', - ); - expect(cache, contains(testMapId)); - expect( - cache[testMapId], - isNotNull, - reason: 'cached controller cannot be null.', - ); - }); - - testWidgets('returns cached instance if it already exists', - (WidgetTester tester) async { - final expected = HtmlElementView(viewType: 'only-for-testing'); - when(controller.widget).thenReturn(expected); - plugin.debugSetMapById({testMapId: controller}); - - final widget = plugin.buildView( - testMapId, - onPlatformViewCreated, - initialCameraPosition: initialCameraPosition, - ); - - expect(widget, equals(expected)); - expect( - reportedMapId, - isNull, - reason: - 'onPlatformViewCreated should not be called when returning a cached controller', - ); - }); - }); - - group('setMapStyles', () { - String mapStyle = '''[{ - "featureType": "poi.park", - "elementType": "labels.text.fill", - "stylers": [{"color": "#6b9a76"}] - }]'''; - - testWidgets('translates styles for controller', - (WidgetTester tester) async { - plugin.debugSetMapById({0: controller}); - - await plugin.setMapStyle(mapStyle, mapId: 0); - - var captured = - verify(controller.updateRawOptions(captureThat(isMap))).captured[0]; - - expect(captured, contains('styles')); - var styles = captured['styles']; - expect(styles.length, 1); - // Let's peek inside the styles... - var style = styles[0] as gmaps.MapTypeStyle; - expect(style.featureType, 'poi.park'); - expect(style.elementType, 'labels.text.fill'); - expect(style.stylers?.length, 1); - expect(getProperty(style.stylers![0]!, 'color'), '#6b9a76'); - }); - }); - - group('Noop methods:', () { - int mapId = 0; - setUp(() { - plugin.debugSetMapById({mapId: controller}); - }); - // Options - testWidgets('updateTileOverlays', (WidgetTester tester) async { - final update = - plugin.updateTileOverlays(mapId: mapId, newTileOverlays: {}); - expect(update, completion(null)); - }); - testWidgets('updateTileOverlays', (WidgetTester tester) async { - final update = - plugin.clearTileCache(TileOverlayId('any'), mapId: mapId); - expect(update, completion(null)); - }); - }); - - // These methods only pass-through values from the plugin to the controller - // so we verify them all together here... - group('Pass-through methods:', () { - int mapId = 0; - setUp(() { - plugin.debugSetMapById({mapId: controller}); - }); - // Options - testWidgets('updateMapOptions', (WidgetTester tester) async { - final expectedMapOptions = {'someOption': 12345}; - - await plugin.updateMapOptions(expectedMapOptions, mapId: mapId); - - verify(controller.updateRawOptions(expectedMapOptions)); - }); - // Geometry - testWidgets('updateMarkers', (WidgetTester tester) async { - final expectedUpdates = MarkerUpdates.from({}, {}); - - await plugin.updateMarkers(expectedUpdates, mapId: mapId); - - verify(controller.updateMarkers(expectedUpdates)); - }); - testWidgets('updatePolygons', (WidgetTester tester) async { - final expectedUpdates = PolygonUpdates.from({}, {}); - - await plugin.updatePolygons(expectedUpdates, mapId: mapId); - - verify(controller.updatePolygons(expectedUpdates)); - }); - testWidgets('updatePolylines', (WidgetTester tester) async { - final expectedUpdates = PolylineUpdates.from({}, {}); - - await plugin.updatePolylines(expectedUpdates, mapId: mapId); - - verify(controller.updatePolylines(expectedUpdates)); - }); - testWidgets('updateCircles', (WidgetTester tester) async { - final expectedUpdates = CircleUpdates.from({}, {}); - - await plugin.updateCircles(expectedUpdates, mapId: mapId); - - verify(controller.updateCircles(expectedUpdates)); - }); - // Camera - testWidgets('animateCamera', (WidgetTester tester) async { - final expectedUpdates = - CameraUpdate.newLatLng(LatLng(43.3626, -5.8433)); - - await plugin.animateCamera(expectedUpdates, mapId: mapId); - - verify(controller.moveCamera(expectedUpdates)); - }); - testWidgets('moveCamera', (WidgetTester tester) async { - final expectedUpdates = - CameraUpdate.newLatLng(LatLng(43.3628, -5.8478)); - - await plugin.moveCamera(expectedUpdates, mapId: mapId); - - verify(controller.moveCamera(expectedUpdates)); - }); - - // Viewport - testWidgets('getVisibleRegion', (WidgetTester tester) async { - when(controller.getVisibleRegion()) - .thenAnswer((_) async => LatLngBounds( - northeast: LatLng(47.2359634, -68.0192019), - southwest: LatLng(34.5019594, -120.4974629), - )); - await plugin.getVisibleRegion(mapId: mapId); - - verify(controller.getVisibleRegion()); - }); - - testWidgets('getZoomLevel', (WidgetTester tester) async { - when(controller.getZoomLevel()).thenAnswer((_) async => 10); - await plugin.getZoomLevel(mapId: mapId); - - verify(controller.getZoomLevel()); - }); - - testWidgets('getScreenCoordinate', (WidgetTester tester) async { - when(controller.getScreenCoordinate(any)).thenAnswer( - (_) async => ScreenCoordinate(x: 320, y: 240) // fake return - ); - - final latLng = LatLng(43.3613, -5.8499); - - await plugin.getScreenCoordinate(latLng, mapId: mapId); - - verify(controller.getScreenCoordinate(latLng)); - }); - - testWidgets('getLatLng', (WidgetTester tester) async { - when(controller.getLatLng(any)) - .thenAnswer((_) async => LatLng(43.3613, -5.8499) // fake return - ); - - final coordinates = ScreenCoordinate(x: 19, y: 26); - - await plugin.getLatLng(coordinates, mapId: mapId); - - verify(controller.getLatLng(coordinates)); - }); - - // InfoWindows - testWidgets('showMarkerInfoWindow', (WidgetTester tester) async { - final markerId = MarkerId('testing-123'); - - await plugin.showMarkerInfoWindow(markerId, mapId: mapId); - - verify(controller.showInfoWindow(markerId)); - }); - - testWidgets('hideMarkerInfoWindow', (WidgetTester tester) async { - final markerId = MarkerId('testing-123'); - - await plugin.hideMarkerInfoWindow(markerId, mapId: mapId); - - verify(controller.hideInfoWindow(markerId)); - }); - - testWidgets('isMarkerInfoWindowShown', (WidgetTester tester) async { - when(controller.isInfoWindowShown(any)).thenReturn(true); - - final markerId = MarkerId('testing-123'); - - await plugin.isMarkerInfoWindowShown(markerId, mapId: mapId); - - verify(controller.isInfoWindowShown(markerId)); - }); - }); - - // Verify all event streams are filtered correctly from the main one... - group('Event Streams', () { - int mapId = 0; - late StreamController streamController; - setUp(() { - streamController = StreamController.broadcast(); - when(controller.events) - .thenAnswer((realInvocation) => streamController.stream); - plugin.debugSetMapById({mapId: controller}); - }); - - // Dispatches a few events in the global streamController, and expects *only* the passed event to be there. - Future _testStreamFiltering( - Stream stream, MapEvent event) async { - Timer.run(() { - streamController.add(_OtherMapEvent(mapId)); - streamController.add(event); - streamController.add(_OtherMapEvent(mapId)); - streamController.close(); - }); - - final events = await stream.toList(); - - expect(events.length, 1); - expect(events[0], event); - } - - // Camera events - testWidgets('onCameraMoveStarted', (WidgetTester tester) async { - final event = CameraMoveStartedEvent(mapId); - - final stream = plugin.onCameraMoveStarted(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - testWidgets('onCameraMoveStarted', (WidgetTester tester) async { - final event = CameraMoveEvent( - mapId, - CameraPosition( - target: LatLng(43.3790, -5.8660), - ), - ); - - final stream = plugin.onCameraMove(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - testWidgets('onCameraIdle', (WidgetTester tester) async { - final event = CameraIdleEvent(mapId); - - final stream = plugin.onCameraIdle(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - // Marker events - testWidgets('onMarkerTap', (WidgetTester tester) async { - final event = MarkerTapEvent(mapId, MarkerId('test-123')); - - final stream = plugin.onMarkerTap(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - testWidgets('onInfoWindowTap', (WidgetTester tester) async { - final event = InfoWindowTapEvent(mapId, MarkerId('test-123')); - - final stream = plugin.onInfoWindowTap(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - testWidgets('onMarkerDragEnd', (WidgetTester tester) async { - final event = MarkerDragEndEvent( - mapId, - LatLng(43.3677, -5.8372), - MarkerId('test-123'), - ); - - final stream = plugin.onMarkerDragEnd(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - // Geometry - testWidgets('onPolygonTap', (WidgetTester tester) async { - final event = PolygonTapEvent(mapId, PolygonId('test-123')); - - final stream = plugin.onPolygonTap(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - testWidgets('onPolylineTap', (WidgetTester tester) async { - final event = PolylineTapEvent(mapId, PolylineId('test-123')); - - final stream = plugin.onPolylineTap(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - testWidgets('onCircleTap', (WidgetTester tester) async { - final event = CircleTapEvent(mapId, CircleId('test-123')); - - final stream = plugin.onCircleTap(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - // Map taps - testWidgets('onTap', (WidgetTester tester) async { - final event = MapTapEvent(mapId, LatLng(43.3597, -5.8458)); - - final stream = plugin.onTap(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - testWidgets('onLongPress', (WidgetTester tester) async { - final event = MapLongPressEvent(mapId, LatLng(43.3608, -5.8425)); - - final stream = plugin.onLongPress(mapId: mapId); - - await _testStreamFiltering(stream, event); - }); - }); - }); -} - -class _OtherMapEvent extends MapEvent { - _OtherMapEvent(int mapId) : super(mapId, null); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart deleted file mode 100644 index 43150f63ef93..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/google_maps_plugin_test.mocks.dart +++ /dev/null @@ -1,106 +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. - -// Mocks generated by Mockito 5.0.2 from annotations -// in google_maps_flutter_web_integration_tests/integration_test/google_maps_plugin_test.dart. -// Do not manually edit this file. - -import 'dart:async' as _i5; - -import 'package:google_maps_flutter_platform_interface/src/events/map_event.dart' - as _i6; -import 'package:google_maps_flutter_platform_interface/src/types/camera.dart' - as _i7; -import 'package:google_maps_flutter_platform_interface/src/types/circle_updates.dart' - as _i8; -import 'package:google_maps_flutter_platform_interface/src/types/location.dart' - as _i2; -import 'package:google_maps_flutter_platform_interface/src/types/marker.dart' - as _i12; -import 'package:google_maps_flutter_platform_interface/src/types/marker_updates.dart' - as _i11; -import 'package:google_maps_flutter_platform_interface/src/types/polygon_updates.dart' - as _i9; -import 'package:google_maps_flutter_platform_interface/src/types/polyline_updates.dart' - as _i10; -import 'package:google_maps_flutter_platform_interface/src/types/screen_coordinate.dart' - as _i3; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart' as _i4; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: comment_references -// ignore_for_file: unnecessary_parenthesis - -class _FakeLatLngBounds extends _i1.Fake implements _i2.LatLngBounds {} - -class _FakeScreenCoordinate extends _i1.Fake implements _i3.ScreenCoordinate {} - -class _FakeLatLng extends _i1.Fake implements _i2.LatLng {} - -/// A class which mocks [GoogleMapController]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockGoogleMapController extends _i1.Mock - implements _i4.GoogleMapController { - @override - _i5.Stream<_i6.MapEvent> get events => - (super.noSuchMethod(Invocation.getter(#events), - returnValue: Stream<_i6.MapEvent>.empty()) - as _i5.Stream<_i6.MapEvent>); - @override - void updateRawOptions(Map? optionsUpdate) => - super.noSuchMethod(Invocation.method(#updateRawOptions, [optionsUpdate]), - returnValueForMissingStub: null); - @override - _i5.Future<_i2.LatLngBounds> getVisibleRegion() => - (super.noSuchMethod(Invocation.method(#getVisibleRegion, []), - returnValue: Future.value(_FakeLatLngBounds())) - as _i5.Future<_i2.LatLngBounds>); - @override - _i5.Future<_i3.ScreenCoordinate> getScreenCoordinate(_i2.LatLng? latLng) => - (super.noSuchMethod(Invocation.method(#getScreenCoordinate, [latLng]), - returnValue: Future.value(_FakeScreenCoordinate())) - as _i5.Future<_i3.ScreenCoordinate>); - @override - _i5.Future<_i2.LatLng> getLatLng(_i3.ScreenCoordinate? screenCoordinate) => - (super.noSuchMethod(Invocation.method(#getLatLng, [screenCoordinate]), - returnValue: Future.value(_FakeLatLng())) as _i5.Future<_i2.LatLng>); - @override - _i5.Future moveCamera(_i7.CameraUpdate? cameraUpdate) => - (super.noSuchMethod(Invocation.method(#moveCamera, [cameraUpdate]), - returnValue: Future.value(null), - returnValueForMissingStub: Future.value()) as _i5.Future); - @override - _i5.Future getZoomLevel() => - (super.noSuchMethod(Invocation.method(#getZoomLevel, []), - returnValue: Future.value(0.0)) as _i5.Future); - @override - void updateCircles(_i8.CircleUpdates? updates) => - super.noSuchMethod(Invocation.method(#updateCircles, [updates]), - returnValueForMissingStub: null); - @override - void updatePolygons(_i9.PolygonUpdates? updates) => - super.noSuchMethod(Invocation.method(#updatePolygons, [updates]), - returnValueForMissingStub: null); - @override - void updatePolylines(_i10.PolylineUpdates? updates) => - super.noSuchMethod(Invocation.method(#updatePolylines, [updates]), - returnValueForMissingStub: null); - @override - void updateMarkers(_i11.MarkerUpdates? updates) => - super.noSuchMethod(Invocation.method(#updateMarkers, [updates]), - returnValueForMissingStub: null); - @override - void showInfoWindow(_i12.MarkerId? markerId) => - super.noSuchMethod(Invocation.method(#showInfoWindow, [markerId]), - returnValueForMissingStub: null); - @override - void hideInfoWindow(_i12.MarkerId? markerId) => - super.noSuchMethod(Invocation.method(#hideInfoWindow, [markerId]), - returnValueForMissingStub: null); - @override - bool isInfoWindowShown(_i12.MarkerId? markerId) => - (super.noSuchMethod(Invocation.method(#isInfoWindowShown, [markerId]), - returnValue: false) as bool); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_test.dart deleted file mode 100644 index 2bfa27b73a77..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/marker_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:html' as html; - -import 'package:integration_test/integration_test.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Test Markers -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - // Since onTap/DragEnd events happen asynchronously, we need to store when the event - // is fired. We use a completer so the test can wait for the future to be completed. - late Completer _methodCalledCompleter; - - /// This is the future value of the [_methodCalledCompleter]. Reinitialized - /// in the [setUp] method, and completed (as `true`) by [onTap] and [onDragEnd] - /// when those methods are called from the MarkerController. - late Future methodCalled; - - void onTap() { - _methodCalledCompleter.complete(true); - } - - void onDragEnd(gmaps.LatLng _) { - _methodCalledCompleter.complete(true); - } - - setUp(() { - _methodCalledCompleter = Completer(); - methodCalled = _methodCalledCompleter.future; - }); - - group('MarkerController', () { - late gmaps.Marker marker; - - setUp(() { - marker = gmaps.Marker(); - }); - - testWidgets('onTap gets called', (WidgetTester tester) async { - MarkerController(marker: marker, onTap: onTap); - - // Trigger a click event... - gmaps.Event.trigger(marker, 'click', [gmaps.MapMouseEvent()]); - - // The event handling is now truly async. Wait for it... - expect(await methodCalled, isTrue); - }); - - testWidgets('onDragEnd gets called', (WidgetTester tester) async { - MarkerController(marker: marker, onDragEnd: onDragEnd); - - // Trigger a drag end event... - gmaps.Event.trigger(marker, 'dragend', - [gmaps.MapMouseEvent()..latLng = gmaps.LatLng(0, 0)]); - - expect(await methodCalled, isTrue); - }); - - testWidgets('update', (WidgetTester tester) async { - final controller = MarkerController(marker: marker); - final options = gmaps.MarkerOptions()..draggable = true; - - expect(marker.draggable, isNull); - - controller.update(options); - - expect(marker.draggable, isTrue); - }); - - testWidgets('infoWindow null, showInfoWindow.', - (WidgetTester tester) async { - final controller = MarkerController(marker: marker); - - controller.showInfoWindow(); - - expect(controller.infoWindowShown, isFalse); - }); - - testWidgets('showInfoWindow', (WidgetTester tester) async { - final infoWindow = gmaps.InfoWindow(); - final map = gmaps.GMap(html.DivElement()); - marker.set('map', map); - final controller = - MarkerController(marker: marker, infoWindow: infoWindow); - - controller.showInfoWindow(); - - expect(infoWindow.get('map'), map); - expect(controller.infoWindowShown, isTrue); - }); - - testWidgets('hideInfoWindow', (WidgetTester tester) async { - final infoWindow = gmaps.InfoWindow(); - final map = gmaps.GMap(html.DivElement()); - marker.set('map', map); - final controller = - MarkerController(marker: marker, infoWindow: infoWindow); - - controller.hideInfoWindow(); - - expect(infoWindow.get('map'), isNull); - expect(controller.infoWindowShown, isFalse); - }); - - group('remove', () { - late MarkerController controller; - - setUp(() { - final infoWindow = gmaps.InfoWindow(); - final map = gmaps.GMap(html.DivElement()); - marker.set('map', map); - controller = MarkerController(marker: marker, infoWindow: infoWindow); - }); - - testWidgets('drops gmaps instance', (WidgetTester tester) async { - controller.remove(); - - expect(controller.marker, isNull); - }); - - testWidgets('cannot call update after remove', - (WidgetTester tester) async { - final options = gmaps.MarkerOptions()..draggable = true; - - controller.remove(); - - expect(() { - controller.update(options); - }, throwsAssertionError); - }); - - testWidgets('cannot call showInfoWindow after remove', - (WidgetTester tester) async { - controller.remove(); - - expect(() { - controller.showInfoWindow(); - }, throwsAssertionError); - }); - - testWidgets('cannot call hideInfoWindow after remove', - (WidgetTester tester) async { - controller.remove(); - - expect(() { - controller.hideInfoWindow(); - }, throwsAssertionError); - }); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart deleted file mode 100644 index 6f2bf610f77d..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/markers_test.dart +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:html' as html; -import 'dart:js_util' show getProperty; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; -import 'package:http/http.dart' as http; -import 'package:integration_test/integration_test.dart'; - -import 'resources/icon_image_base64.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('MarkersController', () { - late StreamController events; - late MarkersController controller; - late gmaps.GMap map; - - setUp(() { - events = StreamController(); - controller = MarkersController(stream: events); - map = gmaps.GMap(html.DivElement()); - controller.bindToMap(123, map); - }); - - testWidgets('addMarkers', (WidgetTester tester) async { - final markers = { - Marker(markerId: MarkerId('1')), - Marker(markerId: MarkerId('2')), - }; - - controller.addMarkers(markers); - - expect(controller.markers.length, 2); - expect(controller.markers, contains(MarkerId('1'))); - expect(controller.markers, contains(MarkerId('2'))); - expect(controller.markers, isNot(contains(MarkerId('66')))); - }); - - testWidgets('changeMarkers', (WidgetTester tester) async { - final markers = { - Marker(markerId: MarkerId('1')), - }; - controller.addMarkers(markers); - - expect(controller.markers[MarkerId('1')]?.marker?.draggable, isFalse); - - // Update the marker with radius 10 - final updatedMarkers = { - Marker(markerId: MarkerId('1'), draggable: true), - }; - controller.changeMarkers(updatedMarkers); - - expect(controller.markers.length, 1); - expect(controller.markers[MarkerId('1')]?.marker?.draggable, isTrue); - }); - - testWidgets('removeMarkers', (WidgetTester tester) async { - final markers = { - Marker(markerId: MarkerId('1')), - Marker(markerId: MarkerId('2')), - Marker(markerId: MarkerId('3')), - }; - - controller.addMarkers(markers); - - expect(controller.markers.length, 3); - - // Remove some markers... - final markerIdsToRemove = { - MarkerId('1'), - MarkerId('3'), - }; - - controller.removeMarkers(markerIdsToRemove); - - expect(controller.markers.length, 1); - expect(controller.markers, isNot(contains(MarkerId('1')))); - expect(controller.markers, contains(MarkerId('2'))); - expect(controller.markers, isNot(contains(MarkerId('3')))); - }); - - testWidgets('InfoWindow show/hide', (WidgetTester tester) async { - final markers = { - Marker( - markerId: MarkerId('1'), - infoWindow: InfoWindow(title: "Title", snippet: "Snippet"), - ), - }; - - controller.addMarkers(markers); - - expect(controller.markers[MarkerId('1')]?.infoWindowShown, isFalse); - - controller.showMarkerInfoWindow(MarkerId('1')); - - expect(controller.markers[MarkerId('1')]?.infoWindowShown, isTrue); - - controller.hideMarkerInfoWindow(MarkerId('1')); - - expect(controller.markers[MarkerId('1')]?.infoWindowShown, isFalse); - }); - - // https://github.com/flutter/flutter/issues/67380 - testWidgets('only single InfoWindow is visible', - (WidgetTester tester) async { - final markers = { - Marker( - markerId: MarkerId('1'), - infoWindow: InfoWindow(title: "Title", snippet: "Snippet"), - ), - Marker( - markerId: MarkerId('2'), - infoWindow: InfoWindow(title: "Title", snippet: "Snippet"), - ), - }; - controller.addMarkers(markers); - - expect(controller.markers[MarkerId('1')]?.infoWindowShown, isFalse); - expect(controller.markers[MarkerId('2')]?.infoWindowShown, isFalse); - - controller.showMarkerInfoWindow(MarkerId('1')); - - expect(controller.markers[MarkerId('1')]?.infoWindowShown, isTrue); - expect(controller.markers[MarkerId('2')]?.infoWindowShown, isFalse); - - controller.showMarkerInfoWindow(MarkerId('2')); - - expect(controller.markers[MarkerId('1')]?.infoWindowShown, isFalse); - expect(controller.markers[MarkerId('2')]?.infoWindowShown, isTrue); - }); - - // https://github.com/flutter/flutter/issues/66622 - testWidgets('markers with custom bitmap icon work', - (WidgetTester tester) async { - final bytes = Base64Decoder().convert(iconImageBase64); - final markers = { - Marker( - markerId: MarkerId('1'), icon: BitmapDescriptor.fromBytes(bytes)), - }; - - controller.addMarkers(markers); - - expect(controller.markers.length, 1); - expect(controller.markers[MarkerId('1')]?.marker?.icon, isNotNull); - - final blobUrl = getProperty( - controller.markers[MarkerId('1')]!.marker!.icon!, - 'url', - ); - - expect(blobUrl, startsWith('blob:')); - - final response = await http.get(Uri.parse(blobUrl)); - - expect(response.bodyBytes, bytes, - reason: - 'Bytes from the Icon blob must match bytes used to create Marker'); - }); - - // https://github.com/flutter/flutter/issues/67854 - testWidgets('InfoWindow snippet can have links', - (WidgetTester tester) async { - final markers = { - Marker( - markerId: MarkerId('1'), - infoWindow: InfoWindow( - title: 'title for test', - snippet: 'Go to Google >>>', - ), - ), - }; - - controller.addMarkers(markers); - - expect(controller.markers.length, 1); - final content = controller.markers[MarkerId('1')]?.infoWindow?.content - as html.HtmlElement; - expect(content.innerHtml, contains('title for test')); - expect( - content.innerHtml, - contains( - 'Go to Google >>>')); - }); - - // https://github.com/flutter/flutter/issues/67289 - testWidgets('InfoWindow content is clickable', (WidgetTester tester) async { - final markers = { - Marker( - markerId: MarkerId('1'), - infoWindow: InfoWindow( - title: 'title for test', - snippet: 'some snippet', - ), - ), - }; - - controller.addMarkers(markers); - - expect(controller.markers.length, 1); - final content = controller.markers[MarkerId('1')]?.infoWindow?.content - as html.HtmlElement; - - content.click(); - - final event = await events.stream.first; - - expect(event, isA()); - expect((event as InfoWindowTapEvent).value, equals(MarkerId('1'))); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/projection_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/projection_test.dart new file mode 100644 index 000000000000..8a5a62013538 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/projection_test.dart @@ -0,0 +1,265 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// These tests render an app with a small map widget, and use its map controller +// to compute values of the default projection. + +// (Tests methods that can't be mocked in `google_maps_controller_test.dart`) + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter/google_maps_flutter.dart' + show GoogleMap, GoogleMapController; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +// This value is used when comparing long~num, like LatLng values. +const _acceptableLatLngDelta = 0.0000000001; + +// This value is used when comparing pixel measurements, mostly to gloss over +// browser rounding errors. +const _acceptablePixelDelta = 1; + +/// Test Google Map Controller +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Methods that require a proper Projection', () { + final LatLng center = LatLng(43.3078, -5.6958); + final Size size = Size(320, 240); + final CameraPosition initialCamera = CameraPosition( + target: center, + zoom: 14, + ); + + late Completer controllerCompleter; + late void Function(GoogleMapController) onMapCreated; + + setUp(() { + controllerCompleter = Completer(); + onMapCreated = (GoogleMapController mapController) { + controllerCompleter.complete(mapController); + }; + }); + + group('getScreenCoordinate', () { + testWidgets('target of map is in center of widget', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + + final GoogleMapController controller = await controllerCompleter.future; + + final ScreenCoordinate screenPosition = + await controller.getScreenCoordinate(center); + + expect( + screenPosition.x, + closeTo(size.width / 2, _acceptablePixelDelta), + ); + expect( + screenPosition.y, + closeTo(size.height / 2, _acceptablePixelDelta), + ); + }); + + testWidgets('NorthWest of visible region corresponds to x:0, y:0', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng northWest = LatLng( + bounds.northeast.latitude, + bounds.southwest.longitude, + ); + + final ScreenCoordinate screenPosition = + await controller.getScreenCoordinate(northWest); + + expect(screenPosition.x, closeTo(0, _acceptablePixelDelta)); + expect(screenPosition.y, closeTo(0, _acceptablePixelDelta)); + }); + + testWidgets( + 'SouthEast of visible region corresponds to x:size.width, y:size.height', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng southEast = LatLng( + bounds.southwest.latitude, + bounds.northeast.longitude, + ); + + final ScreenCoordinate screenPosition = + await controller.getScreenCoordinate(southEast); + + expect(screenPosition.x, closeTo(size.width, _acceptablePixelDelta)); + expect(screenPosition.y, closeTo(size.height, _acceptablePixelDelta)); + }); + }); + + group('getLatLng', () { + testWidgets('Center of widget is the target of map', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + + final GoogleMapController controller = await controllerCompleter.future; + + final LatLng coords = await controller.getLatLng( + ScreenCoordinate(x: size.width ~/ 2, y: size.height ~/ 2), + ); + + expect( + coords.latitude, + closeTo(center.latitude, _acceptableLatLngDelta), + ); + expect( + coords.longitude, + closeTo(center.longitude, _acceptableLatLngDelta), + ); + }); + + testWidgets('Top-left of widget is NorthWest bound of map', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng northWest = LatLng( + bounds.northeast.latitude, + bounds.southwest.longitude, + ); + + final LatLng coords = await controller.getLatLng( + ScreenCoordinate(x: 0, y: 0), + ); + + expect( + coords.latitude, + closeTo(northWest.latitude, _acceptableLatLngDelta), + ); + expect( + coords.longitude, + closeTo(northWest.longitude, _acceptableLatLngDelta), + ); + }); + + testWidgets('Bottom-right of widget is SouthWest bound of map', + (WidgetTester tester) async { + pumpCenteredMap( + tester, + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ); + final GoogleMapController controller = await controllerCompleter.future; + + final LatLngBounds bounds = await controller.getVisibleRegion(); + final LatLng southEast = LatLng( + bounds.southwest.latitude, + bounds.northeast.longitude, + ); + + final LatLng coords = await controller.getLatLng( + ScreenCoordinate(x: size.width.toInt(), y: size.height.toInt()), + ); + + expect( + coords.latitude, + closeTo(southEast.latitude, _acceptableLatLngDelta), + ); + expect( + coords.longitude, + closeTo(southEast.longitude, _acceptableLatLngDelta), + ); + }); + }); + }); +} + +// Pumps a CenteredMap Widget into a given tester, with some parameters +void pumpCenteredMap( + WidgetTester tester, { + required CameraPosition initialCamera, + Size size = const Size(320, 240), + void Function(GoogleMapController)? onMapCreated, +}) async { + await tester.pumpWidget( + CenteredMap( + initialCamera: initialCamera, + size: size, + onMapCreated: onMapCreated, + ), + ); + + // This is needed to kick-off the rendering of the JS Map flutter widget + await tester.pump(); +} + +/// Renders a Map widget centered on the screen. +/// This depends in `package:google_maps_flutter` to work. +class CenteredMap extends StatelessWidget { + const CenteredMap({ + required this.initialCamera, + required this.size, + required this.onMapCreated, + Key? key, + }) : super(key: key); + + /// A function that receives the [GoogleMapController] of the Map widget once initialized. + final void Function(GoogleMapController)? onMapCreated; + + /// The size of the rendered map widget. + final Size size; + + /// The initial camera position (center + zoom level) of the Map widget. + final CameraPosition initialCamera; + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox.fromSize( + size: size, + child: GoogleMap( + initialCameraPosition: initialCamera, + onMapCreated: onMapCreated, + ), + ), + ), + ), + ); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/resources/icon_image_base64.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/resources/icon_image_base64.dart deleted file mode 100644 index 6010f0107031..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/resources/icon_image_base64.dart +++ /dev/null @@ -1,28 +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. - -final iconImageBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAIRlWElmTU' - '0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIA' - 'AIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQ' - 'AAABCgAwAEAAAAAQAAABAAAAAAx28c8QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1M' - 'OmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIH' - 'g6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8v' - 'd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcm' - 'lwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFk' - 'b2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk' - '9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6' - 'eG1wbWV0YT4KTMInWQAAAplJREFUOBF1k01ME1EQx2fe7tIPoGgTE6AJgQQSPaiH9oAtkFbsgX' - 'jygFcT0XjSkxcTDxtPJh6MR28ePMHBBA8cNLSIony0oBhEMVETP058tE132+7uG3cW24DAXN57' - '2fn9/zPz3iIcEdEl0nIxtNLr1IlVeoMadkubKmoL+u2SzAV8IjV5Ekt4GN+A8+VOUPwLarOI2G' - 'Vpqq0i4JQorwQxPtWHVZ1IKP8LNGDXGaSyqARFxDGo7MJBy4XVf3AyQ+qTHnTEXoF9cFUy3OkY' - '0oWxmWFtD5xNoc1sQ6AOn1+hCNTkkhKow8KFZV77tVs2O9dhFvBm0IA/U0RhZ7/ocEx23oUDlh' - 'h8HkNjZIN8Lb3gOU8gOp7AKJHCB2/aNZkTftHumNzzbtl2CBPZHqxw8mHhVZBeoz6w5DvhE2FZ' - 'lQYPjKdd2/qRyKZ6KsPv7TEk7EYEk0A0EUmJduHRy1i4oLKqgmC59ZggAdwrC9pFuWy1iUT2rA' - 'uv0h2UdNtNqxCBBkgqorjOMOgksN7CxQ90vEb00U3c3LIwyo9o8FXxQVNr8Coqyk+S5EPBXnjt' - 'xRmc4TegI7qWbvBkeeUbGMnTCd4nZnYeDOWIEtlC6cKK/JJepY3hZSvN33jovO6L0XFqPKqBTO' - 'FuapUoPr1lxDM7cmC2TAOz25cYSGa++feBew/cjpc0V+mNT29/HZp3KDFTNLvuTRPEHy5065lj' - 'Xn4y41XM+wP/AlcycRmdc3MUhvLm/J/ceu/3qUVT62oP2EZpjSylHybHSpDUVcjq9gEBVo0+Xt' - 'JyN2IWRO+3QUforRoKnZLVsglaMECW+YmMSj9M3SrC6Lg71CMiqWfUrJ6ywzefhnZ+G69BaKdB' - 'WhXQAn6wzDUpfUPw7MrmX/WhbfmKblw+AAAAAElFTkSuQmCC'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shape_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shape_test.dart deleted file mode 100644 index 547aaec6dc0a..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shape_test.dart +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; - -import 'package:integration_test/integration_test.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Test Shapes (Circle, Polygon, Polyline) -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - // Since onTap events happen asynchronously, we need to store when the event - // is fired. We use a completer so the test can wait for the future to be completed. - late Completer _methodCalledCompleter; - - /// This is the future value of the [_methodCalledCompleter]. Reinitialized - /// in the [setUp] method, and completed (as `true`) by [onTap], when it gets - /// called by the corresponding Shape Controller. - late Future methodCalled; - - void onTap() { - _methodCalledCompleter.complete(true); - } - - setUp(() { - _methodCalledCompleter = Completer(); - methodCalled = _methodCalledCompleter.future; - }); - - group('CircleController', () { - late gmaps.Circle circle; - - setUp(() { - circle = gmaps.Circle(); - }); - - testWidgets('onTap gets called', (WidgetTester tester) async { - CircleController(circle: circle, consumeTapEvents: true, onTap: onTap); - - // Trigger a click event... - gmaps.Event.trigger(circle, 'click', [gmaps.MapMouseEvent()]); - - // The event handling is now truly async. Wait for it... - expect(await methodCalled, isTrue); - }); - - testWidgets('update', (WidgetTester tester) async { - final controller = CircleController(circle: circle); - final options = gmaps.CircleOptions()..draggable = true; - - expect(circle.draggable, isNull); - - controller.update(options); - - expect(circle.draggable, isTrue); - }); - - group('remove', () { - late CircleController controller; - - setUp(() { - controller = CircleController(circle: circle); - }); - - testWidgets('drops gmaps instance', (WidgetTester tester) async { - controller.remove(); - - expect(controller.circle, isNull); - }); - - testWidgets('cannot call update after remove', - (WidgetTester tester) async { - final options = gmaps.CircleOptions()..draggable = true; - - controller.remove(); - - expect(() { - controller.update(options); - }, throwsAssertionError); - }); - }); - }); - - group('PolygonController', () { - late gmaps.Polygon polygon; - - setUp(() { - polygon = gmaps.Polygon(); - }); - - testWidgets('onTap gets called', (WidgetTester tester) async { - PolygonController(polygon: polygon, consumeTapEvents: true, onTap: onTap); - - // Trigger a click event... - gmaps.Event.trigger(polygon, 'click', [gmaps.MapMouseEvent()]); - - // The event handling is now truly async. Wait for it... - expect(await methodCalled, isTrue); - }); - - testWidgets('update', (WidgetTester tester) async { - final controller = PolygonController(polygon: polygon); - final options = gmaps.PolygonOptions()..draggable = true; - - expect(polygon.draggable, isNull); - - controller.update(options); - - expect(polygon.draggable, isTrue); - }); - - group('remove', () { - late PolygonController controller; - - setUp(() { - controller = PolygonController(polygon: polygon); - }); - - testWidgets('drops gmaps instance', (WidgetTester tester) async { - controller.remove(); - - expect(controller.polygon, isNull); - }); - - testWidgets('cannot call update after remove', - (WidgetTester tester) async { - final options = gmaps.PolygonOptions()..draggable = true; - - controller.remove(); - - expect(() { - controller.update(options); - }, throwsAssertionError); - }); - }); - }); - - group('PolylineController', () { - late gmaps.Polyline polyline; - - setUp(() { - polyline = gmaps.Polyline(); - }); - - testWidgets('onTap gets called', (WidgetTester tester) async { - PolylineController( - polyline: polyline, consumeTapEvents: true, onTap: onTap); - - // Trigger a click event... - gmaps.Event.trigger(polyline, 'click', [gmaps.MapMouseEvent()]); - - // The event handling is now truly async. Wait for it... - expect(await methodCalled, isTrue); - }); - - testWidgets('update', (WidgetTester tester) async { - final controller = PolylineController(polyline: polyline); - final options = gmaps.PolylineOptions()..draggable = true; - - expect(polyline.draggable, isNull); - - controller.update(options); - - expect(polyline.draggable, isTrue); - }); - - group('remove', () { - late PolylineController controller; - - setUp(() { - controller = PolylineController(polyline: polyline); - }); - - testWidgets('drops gmaps instance', (WidgetTester tester) async { - controller.remove(); - - expect(controller.line, isNull); - }); - - testWidgets('cannot call update after remove', - (WidgetTester tester) async { - final options = gmaps.PolylineOptions()..draggable = true; - - controller.remove(); - - expect(() { - controller.update(options); - }, throwsAssertionError); - }); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shapes_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shapes_test.dart deleted file mode 100644 index 80b4e0823bb5..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/integration_test/shapes_test.dart +++ /dev/null @@ -1,368 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; -import 'dart:ui'; -import 'dart:html' as html; - -import 'package:integration_test/integration_test.dart'; -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; -import 'package:google_maps_flutter_web/google_maps_flutter_web.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; -import 'package:google_maps/google_maps_geometry.dart' as geometry; -import 'package:flutter_test/flutter_test.dart'; - -// This value is used when comparing the results of -// converting from a byte value to a double between 0 and 1. -// (For Color opacity values, for example) -const _acceptableDelta = 0.01; - -/// Test Shapes (Circle, Polygon, Polyline) -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - late gmaps.GMap map; - - setUp(() { - map = gmaps.GMap(html.DivElement()); - }); - - group('CirclesController', () { - late StreamController events; - late CirclesController controller; - - setUp(() { - events = StreamController(); - controller = CirclesController(stream: events); - controller.bindToMap(123, map); - }); - - testWidgets('addCircles', (WidgetTester tester) async { - final circles = { - Circle(circleId: CircleId('1')), - Circle(circleId: CircleId('2')), - }; - - controller.addCircles(circles); - - expect(controller.circles.length, 2); - expect(controller.circles, contains(CircleId('1'))); - expect(controller.circles, contains(CircleId('2'))); - expect(controller.circles, isNot(contains(CircleId('66')))); - }); - - testWidgets('changeCircles', (WidgetTester tester) async { - final circles = { - Circle(circleId: CircleId('1')), - }; - controller.addCircles(circles); - - expect(controller.circles[CircleId('1')]?.circle?.visible, isTrue); - - final updatedCircles = { - Circle(circleId: CircleId('1'), visible: false), - }; - controller.changeCircles(updatedCircles); - - expect(controller.circles.length, 1); - expect(controller.circles[CircleId('1')]?.circle?.visible, isFalse); - }); - - testWidgets('removeCircles', (WidgetTester tester) async { - final circles = { - Circle(circleId: CircleId('1')), - Circle(circleId: CircleId('2')), - Circle(circleId: CircleId('3')), - }; - - controller.addCircles(circles); - - expect(controller.circles.length, 3); - - // Remove some circles... - final circleIdsToRemove = { - CircleId('1'), - CircleId('3'), - }; - - controller.removeCircles(circleIdsToRemove); - - expect(controller.circles.length, 1); - expect(controller.circles, isNot(contains(CircleId('1')))); - expect(controller.circles, contains(CircleId('2'))); - expect(controller.circles, isNot(contains(CircleId('3')))); - }); - - testWidgets('Converts colors to CSS', (WidgetTester tester) async { - final circles = { - Circle( - circleId: CircleId('1'), - fillColor: Color(0x7FFABADA), - strokeColor: Color(0xFFC0FFEE), - ), - }; - - controller.addCircles(circles); - - final circle = controller.circles.values.first.circle!; - - expect(circle.get('fillColor'), '#fabada'); - expect(circle.get('fillOpacity'), closeTo(0.5, _acceptableDelta)); - expect(circle.get('strokeColor'), '#c0ffee'); - expect(circle.get('strokeOpacity'), closeTo(1, _acceptableDelta)); - }); - }); - - group('PolygonsController', () { - late StreamController events; - late PolygonsController controller; - - setUp(() { - events = StreamController(); - controller = PolygonsController(stream: events); - controller.bindToMap(123, map); - }); - - testWidgets('addPolygons', (WidgetTester tester) async { - final polygons = { - Polygon(polygonId: PolygonId('1')), - Polygon(polygonId: PolygonId('2')), - }; - - controller.addPolygons(polygons); - - expect(controller.polygons.length, 2); - expect(controller.polygons, contains(PolygonId('1'))); - expect(controller.polygons, contains(PolygonId('2'))); - expect(controller.polygons, isNot(contains(PolygonId('66')))); - }); - - testWidgets('changePolygons', (WidgetTester tester) async { - final polygons = { - Polygon(polygonId: PolygonId('1')), - }; - controller.addPolygons(polygons); - - expect(controller.polygons[PolygonId('1')]?.polygon?.visible, isTrue); - - // Update the polygon - final updatedPolygons = { - Polygon(polygonId: PolygonId('1'), visible: false), - }; - controller.changePolygons(updatedPolygons); - - expect(controller.polygons.length, 1); - expect(controller.polygons[PolygonId('1')]?.polygon?.visible, isFalse); - }); - - testWidgets('removePolygons', (WidgetTester tester) async { - final polygons = { - Polygon(polygonId: PolygonId('1')), - Polygon(polygonId: PolygonId('2')), - Polygon(polygonId: PolygonId('3')), - }; - - controller.addPolygons(polygons); - - expect(controller.polygons.length, 3); - - // Remove some polygons... - final polygonIdsToRemove = { - PolygonId('1'), - PolygonId('3'), - }; - - controller.removePolygons(polygonIdsToRemove); - - expect(controller.polygons.length, 1); - expect(controller.polygons, isNot(contains(PolygonId('1')))); - expect(controller.polygons, contains(PolygonId('2'))); - expect(controller.polygons, isNot(contains(PolygonId('3')))); - }); - - testWidgets('Converts colors to CSS', (WidgetTester tester) async { - final polygons = { - Polygon( - polygonId: PolygonId('1'), - fillColor: Color(0x7FFABADA), - strokeColor: Color(0xFFC0FFEE), - ), - }; - - controller.addPolygons(polygons); - - final polygon = controller.polygons.values.first.polygon!; - - expect(polygon.get('fillColor'), '#fabada'); - expect(polygon.get('fillOpacity'), closeTo(0.5, _acceptableDelta)); - expect(polygon.get('strokeColor'), '#c0ffee'); - expect(polygon.get('strokeOpacity'), closeTo(1, _acceptableDelta)); - }); - - testWidgets('Handle Polygons with holes', (WidgetTester tester) async { - final polygons = { - Polygon( - polygonId: PolygonId('BermudaTriangle'), - points: [ - LatLng(25.774, -80.19), - LatLng(18.466, -66.118), - LatLng(32.321, -64.757), - ], - holes: [ - [ - LatLng(28.745, -70.579), - LatLng(29.57, -67.514), - LatLng(27.339, -66.668), - ], - ], - ), - }; - - controller.addPolygons(polygons); - - expect(controller.polygons.length, 1); - expect(controller.polygons, contains(PolygonId('BermudaTriangle'))); - expect(controller.polygons, isNot(contains(PolygonId('66')))); - }); - - testWidgets('Polygon with hole has a hole', (WidgetTester tester) async { - final polygons = { - Polygon( - polygonId: PolygonId('BermudaTriangle'), - points: [ - LatLng(25.774, -80.19), - LatLng(18.466, -66.118), - LatLng(32.321, -64.757), - ], - holes: [ - [ - LatLng(28.745, -70.579), - LatLng(29.57, -67.514), - LatLng(27.339, -66.668), - ], - ], - ), - }; - - controller.addPolygons(polygons); - - final polygon = controller.polygons.values.first.polygon; - final pointInHole = gmaps.LatLng(28.632, -68.401); - - expect(geometry.Poly.containsLocation(pointInHole, polygon), false); - }); - - testWidgets('Hole Path gets reversed to display correctly', - (WidgetTester tester) async { - final polygons = { - Polygon( - polygonId: PolygonId('BermudaTriangle'), - points: [ - LatLng(25.774, -80.19), - LatLng(18.466, -66.118), - LatLng(32.321, -64.757), - ], - holes: [ - [ - LatLng(27.339, -66.668), - LatLng(29.57, -67.514), - LatLng(28.745, -70.579), - ], - ], - ), - }; - - controller.addPolygons(polygons); - - final paths = controller.polygons.values.first.polygon!.paths!; - - expect(paths.getAt(1)?.getAt(0)?.lat, 28.745); - expect(paths.getAt(1)?.getAt(1)?.lat, 29.57); - expect(paths.getAt(1)?.getAt(2)?.lat, 27.339); - }); - }); - - group('PolylinesController', () { - late StreamController events; - late PolylinesController controller; - - setUp(() { - events = StreamController(); - controller = PolylinesController(stream: events); - controller.bindToMap(123, map); - }); - - testWidgets('addPolylines', (WidgetTester tester) async { - final polylines = { - Polyline(polylineId: PolylineId('1')), - Polyline(polylineId: PolylineId('2')), - }; - - controller.addPolylines(polylines); - - expect(controller.lines.length, 2); - expect(controller.lines, contains(PolylineId('1'))); - expect(controller.lines, contains(PolylineId('2'))); - expect(controller.lines, isNot(contains(PolylineId('66')))); - }); - - testWidgets('changePolylines', (WidgetTester tester) async { - final polylines = { - Polyline(polylineId: PolylineId('1')), - }; - controller.addPolylines(polylines); - - expect(controller.lines[PolylineId('1')]?.line?.visible, isTrue); - - final updatedPolylines = { - Polyline(polylineId: PolylineId('1'), visible: false), - }; - controller.changePolylines(updatedPolylines); - - expect(controller.lines.length, 1); - expect(controller.lines[PolylineId('1')]?.line?.visible, isFalse); - }); - - testWidgets('removePolylines', (WidgetTester tester) async { - final polylines = { - Polyline(polylineId: PolylineId('1')), - Polyline(polylineId: PolylineId('2')), - Polyline(polylineId: PolylineId('3')), - }; - - controller.addPolylines(polylines); - - expect(controller.lines.length, 3); - - // Remove some polylines... - final polylineIdsToRemove = { - PolylineId('1'), - PolylineId('3'), - }; - - controller.removePolylines(polylineIdsToRemove); - - expect(controller.lines.length, 1); - expect(controller.lines, isNot(contains(PolylineId('1')))); - expect(controller.lines, contains(PolylineId('2'))); - expect(controller.lines, isNot(contains(PolylineId('3')))); - }); - - testWidgets('Converts colors to CSS', (WidgetTester tester) async { - final lines = { - Polyline( - polylineId: PolylineId('1'), - color: Color(0x7FFABADA), - ), - }; - - controller.addPolylines(lines); - - final line = controller.lines.values.first.line!; - - expect(line.get('strokeColor'), '#fabada'); - expect(line.get('strokeOpacity'), closeTo(0.5, _acceptableDelta)); - }); - }); -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart deleted file mode 100644 index 6dc2dab572a6..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/google_maps_flutter_web.dart +++ /dev/null @@ -1,40 +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. - -library google_maps_flutter_web; - -import 'dart:async'; -import 'dart:html'; -import 'dart:js_util'; -import 'src/shims/dart_ui.dart' as ui; // Conditionally imports dart:ui in web -import 'dart:convert'; - -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/gestures.dart'; - -import 'package:sanitize_html/sanitize_html.dart'; - -import 'package:stream_transform/stream_transform.dart'; - -import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; - -import 'src/types.dart'; - -part 'src/google_maps_flutter_web.dart'; -part 'src/google_maps_controller.dart'; -part 'src/circle.dart'; -part 'src/circles.dart'; -part 'src/polygon.dart'; -part 'src/polygons.dart'; -part 'src/polyline.dart'; -part 'src/polylines.dart'; -part 'src/marker.dart'; -part 'src/markers.dart'; -part 'src/convert.dart'; diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/circle.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/circle.dart deleted file mode 100644 index 65057d8c869e..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/circle.dart +++ /dev/null @@ -1,51 +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. - -part of google_maps_flutter_web; - -/// The `CircleController` class wraps a [gmaps.Circle] and its `onTap` behavior. -class CircleController { - gmaps.Circle? _circle; - - final bool _consumeTapEvents; - - /// Creates a `CircleController`, which wraps a [gmaps.Circle] object and its `onTap` behavior. - CircleController({ - required gmaps.Circle circle, - bool consumeTapEvents = false, - ui.VoidCallback? onTap, - }) : _circle = circle, - _consumeTapEvents = consumeTapEvents { - if (onTap != null) { - circle.onClick.listen((_) { - onTap.call(); - }); - } - } - - /// Returns the wrapped [gmaps.Circle]. Only used for testing. - @visibleForTesting - gmaps.Circle? get circle => _circle; - - /// Returns `true` if this Controller will use its own `onTap` handler to consume events. - bool get consumeTapEvents => _consumeTapEvents; - - /// Updates the options of the wrapped [gmaps.Circle] object. - /// - /// This cannot be called after [remove]. - void update(gmaps.CircleOptions options) { - assert(_circle != null, 'Cannot `update` Circle after calling `remove`.'); - _circle!.options = options; - } - - /// Disposes of the currently wrapped [gmaps.Circle]. - void remove() { - if (_circle != null) { - _circle!.visible = false; - _circle!.radius = 0; - _circle!.map = null; - _circle = null; - } - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/circles.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/circles.dart deleted file mode 100644 index ae8faa038ea6..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/circles.dart +++ /dev/null @@ -1,80 +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. - -part of google_maps_flutter_web; - -/// This class manages all the [CircleController]s associated to a [GoogleMapController]. -class CirclesController extends GeometryController { - // A cache of [CircleController]s indexed by their [CircleId]. - final Map _circleIdToController; - - // The stream over which circles broadcast their events - StreamController _streamController; - - /// Initialize the cache. The [StreamController] comes from the [GoogleMapController], and is shared with other controllers. - CirclesController({ - required StreamController stream, - }) : _streamController = stream, - _circleIdToController = Map(); - - /// Returns the cache of [CircleController]s. Test only. - @visibleForTesting - Map get circles => _circleIdToController; - - /// Adds a set of [Circle] objects to the cache. - /// - /// Wraps each [Circle] into its corresponding [CircleController]. - void addCircles(Set circlesToAdd) { - circlesToAdd.forEach((circle) { - _addCircle(circle); - }); - } - - void _addCircle(Circle circle) { - if (circle == null) { - return; - } - - final populationOptions = _circleOptionsFromCircle(circle); - gmaps.Circle gmCircle = gmaps.Circle(populationOptions); - gmCircle.map = googleMap; - CircleController controller = CircleController( - circle: gmCircle, - consumeTapEvents: circle.consumeTapEvents, - onTap: () { - _onCircleTap(circle.circleId); - }); - _circleIdToController[circle.circleId] = controller; - } - - /// Updates a set of [Circle] objects with new options. - void changeCircles(Set circlesToChange) { - circlesToChange.forEach((circleToChange) { - _changeCircle(circleToChange); - }); - } - - void _changeCircle(Circle circle) { - final circleController = _circleIdToController[circle.circleId]; - circleController?.update(_circleOptionsFromCircle(circle)); - } - - /// Removes a set of [CircleId]s from the cache. - void removeCircles(Set circleIdsToRemove) { - circleIdsToRemove.forEach((circleId) { - final CircleController? circleController = - _circleIdToController[circleId]; - circleController?.remove(); - _circleIdToController.remove(circleId); - }); - } - - // Handles the global onCircleTap function to funnel events from circles into the stream. - bool _onCircleTap(CircleId circleId) { - // Have you ended here on your debugging? Is this wrong? - // Comment here: https://github.com/flutter/flutter/issues/64084 - _streamController.add(CircleTapEvent(mapId, circleId)); - return _circleIdToController[circleId]?.consumeTapEvents ?? false; - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart deleted file mode 100644 index 2e71c795ff0e..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/convert.dart +++ /dev/null @@ -1,443 +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. - -part of google_maps_flutter_web; - -// Default values for when the gmaps objects return null/undefined values. -final _nullGmapsLatLng = gmaps.LatLng(0, 0); -final _nullGmapsLatLngBounds = - gmaps.LatLngBounds(_nullGmapsLatLng, _nullGmapsLatLng); - -// Defaults taken from the Google Maps Platform SDK documentation. -final _defaultCssColor = '#000000'; -final _defaultCssOpacity = 0.0; - -// Indices in the plugin side don't match with the ones -// in the gmaps lib. This translates from plugin -> gmaps. -final _mapTypeToMapTypeId = { - 0: gmaps.MapTypeId.ROADMAP, // "none" in the plugin - 1: gmaps.MapTypeId.ROADMAP, - 2: gmaps.MapTypeId.SATELLITE, - 3: gmaps.MapTypeId.TERRAIN, - 4: gmaps.MapTypeId.HYBRID, -}; - -// Converts a [Color] into a valid CSS value #RRGGBB. -String _getCssColor(Color color) { - if (color == null) { - return _defaultCssColor; - } - return '#' + color.value.toRadixString(16).padLeft(8, '0').substring(2); -} - -// Extracts the opacity from a [Color]. -double _getCssOpacity(Color color) { - if (color == null) { - return _defaultCssOpacity; - } - return color.opacity; -} - -// Converts options from the plugin into gmaps.MapOptions that can be used by the JS SDK. -// The following options are not handled here, for various reasons: -// The following are not available in web, because the map doesn't rotate there: -// compassEnabled -// rotateGesturesEnabled -// tiltGesturesEnabled -// mapToolbarEnabled is unused in web, there's no "map toolbar" -// myLocationButtonEnabled Widget not available in web yet, it needs to be built on top of the maps widget -// See: https://developers.google.com/maps/documentation/javascript/examples/control-custom -// myLocationEnabled needs to be built through dart:html navigator.geolocation -// See: https://api.dart.dev/stable/2.8.4/dart-html/Geolocation-class.html -// trafficEnabled is handled when creating the GMap object, since it needs to be added as a layer. -// trackCameraPosition is just a boolan value that indicates if the map has an onCameraMove handler. -// indoorViewEnabled seems to not have an equivalent in web -// buildingsEnabled seems to not have an equivalent in web -// padding seems to behave differently in web than mobile. You can't move UI elements in web. -gmaps.MapOptions _rawOptionsToGmapsOptions(Map rawOptions) { - gmaps.MapOptions options = gmaps.MapOptions(); - - if (_mapTypeToMapTypeId.containsKey(rawOptions['mapType'])) { - options.mapTypeId = _mapTypeToMapTypeId[rawOptions['mapType']]; - } - - if (rawOptions['minMaxZoomPreference'] != null) { - options - ..minZoom = rawOptions['minMaxZoomPreference'][0] - ..maxZoom = rawOptions['minMaxZoomPreference'][1]; - } - - if (rawOptions['cameraTargetBounds'] != null) { - // Needs gmaps.MapOptions.restriction and gmaps.MapRestriction - // see: https://developers.google.com/maps/documentation/javascript/reference/map#MapOptions.restriction - } - - if (rawOptions['zoomControlsEnabled'] != null) { - options.zoomControl = rawOptions['zoomControlsEnabled']; - } - - if (rawOptions['styles'] != null) { - options.styles = rawOptions['styles']; - } - - if (rawOptions['scrollGesturesEnabled'] == false || - rawOptions['zoomGesturesEnabled'] == false) { - options.gestureHandling = 'none'; - } else { - options.gestureHandling = 'auto'; - } - - // These don't have any rawOptions entry, but they seem to be off in the native maps. - options.mapTypeControl = false; - options.fullscreenControl = false; - options.streetViewControl = false; - - return options; -} - -gmaps.MapOptions _applyInitialPosition( - CameraPosition initialPosition, - gmaps.MapOptions options, -) { - // Adjust the initial position, if passed... - if (initialPosition != null) { - options.zoom = initialPosition.zoom; - options.center = gmaps.LatLng( - initialPosition.target.latitude, initialPosition.target.longitude); - } - return options; -} - -// Extracts the status of the traffic layer from the rawOptions map. -bool _isTrafficLayerEnabled(Map rawOptions) { - return rawOptions['trafficEnabled'] ?? false; -} - -// The keys we'd expect to see in a serialized MapTypeStyle JSON object. -final _mapStyleKeys = { - 'elementType', - 'featureType', - 'stylers', -}; - -// Checks if the passed in Map contains some of the _mapStyleKeys. -bool _isJsonMapStyle(Map value) { - return _mapStyleKeys.intersection(value.keys.toSet()).isNotEmpty; -} - -// Converts an incoming JSON-encoded Style info, into the correct gmaps array. -List _mapStyles(String? mapStyleJson) { - List styles = []; - if (mapStyleJson != null) { - styles = json - .decode(mapStyleJson, reviver: (key, value) { - if (value is Map && _isJsonMapStyle(value)) { - return gmaps.MapTypeStyle() - ..elementType = value['elementType'] - ..featureType = value['featureType'] - ..stylers = - (value['stylers'] as List).map((e) => jsify(e)).toList(); - } - return value; - }) - .cast() - .toList(); - // .toList calls are required so the JS API understands the underlying data structure. - } - return styles; -} - -gmaps.LatLng _latLngToGmLatLng(LatLng latLng) { - return gmaps.LatLng(latLng.latitude, latLng.longitude); -} - -LatLng _gmLatLngToLatLng(gmaps.LatLng latLng) { - return LatLng(latLng.lat.toDouble(), latLng.lng.toDouble()); -} - -LatLngBounds _gmLatLngBoundsTolatLngBounds(gmaps.LatLngBounds latLngBounds) { - return LatLngBounds( - southwest: _gmLatLngToLatLng(latLngBounds.southWest), - northeast: _gmLatLngToLatLng(latLngBounds.northEast), - ); -} - -CameraPosition _gmViewportToCameraPosition(gmaps.GMap map) { - return CameraPosition( - target: _gmLatLngToLatLng(map.center ?? _nullGmapsLatLng), - bearing: map.heading?.toDouble() ?? 0, - tilt: map.tilt?.toDouble() ?? 0, - zoom: map.zoom?.toDouble() ?? 0, - ); -} - -// Convert plugin objects to gmaps.Options objects -// TODO: Move to their appropriate objects, maybe make these copy constructors: -// Marker.fromMarker(anotherMarker, moreOptions); - -gmaps.InfoWindowOptions? _infoWindowOptionsFromMarker(Marker marker) { - final markerTitle = marker.infoWindow.title ?? ''; - final markerSnippet = marker.infoWindow.snippet ?? ''; - - // If both the title and snippet of an infowindow are empty, we don't really - // want an infowindow... - if ((markerTitle.isEmpty) && (markerSnippet.isEmpty)) { - return null; - } - - // Add an outer wrapper to the contents of the infowindow, we need it to listen - // to click events... - final HtmlElement container = DivElement() - ..id = 'gmaps-marker-${marker.markerId.value}-infowindow'; - - if (markerTitle.isNotEmpty) { - final HtmlElement title = HeadingElement.h3() - ..className = 'infowindow-title' - ..innerText = markerTitle; - container.children.add(title); - } - if (markerSnippet.isNotEmpty) { - final HtmlElement snippet = DivElement() - ..className = 'infowindow-snippet' - ..setInnerHtml( - sanitizeHtml(markerSnippet), - treeSanitizer: NodeTreeSanitizer.trusted, - ); - container.children.add(snippet); - } - - return gmaps.InfoWindowOptions() - ..content = container - ..zIndex = marker.zIndex; - // TODO: Compute the pixelOffset of the infoWindow, from the size of the Marker, - // and the marker.infoWindow.anchor property. -} - -// Computes the options for a new [gmaps.Marker] from an incoming set of options -// [marker], and the existing marker registered with the map: [currentMarker]. -// Preserves the position from the [currentMarker], if set. -gmaps.MarkerOptions _markerOptionsFromMarker( - Marker marker, - gmaps.Marker? currentMarker, -) { - final iconConfig = marker.icon.toJson() as List; - gmaps.Icon? icon; - - if (iconConfig != null) { - if (iconConfig[0] == 'fromAssetImage') { - assert(iconConfig.length >= 2); - // iconConfig[2] contains the DPIs of the screen, but that information is - // already encoded in the iconConfig[1] - - icon = gmaps.Icon() - ..url = ui.webOnlyAssetManager.getAssetUrl(iconConfig[1]); - - // iconConfig[3] may contain the [width, height] of the image, if passed! - if (iconConfig.length >= 4 && iconConfig[3] != null) { - final size = gmaps.Size(iconConfig[3][0], iconConfig[3][1]); - icon - ..size = size - ..scaledSize = size; - } - } else if (iconConfig[0] == 'fromBytes') { - // Grab the bytes, and put them into a blob - List bytes = iconConfig[1]; - final blob = Blob([bytes]); // Let the browser figure out the encoding - icon = gmaps.Icon()..url = Url.createObjectUrlFromBlob(blob); - } - } - return gmaps.MarkerOptions() - ..position = currentMarker?.position ?? - gmaps.LatLng( - marker.position.latitude, - marker.position.longitude, - ) - ..title = sanitizeHtml(marker.infoWindow.title ?? "") - ..zIndex = marker.zIndex - ..visible = marker.visible - ..opacity = marker.alpha - ..draggable = marker.draggable - ..icon = icon; - // TODO: Compute anchor properly, otherwise infowindows attach to the wrong spot. - // Flat and Rotation are not supported directly on the web. -} - -gmaps.CircleOptions _circleOptionsFromCircle(Circle circle) { - final populationOptions = gmaps.CircleOptions() - ..strokeColor = _getCssColor(circle.strokeColor) - ..strokeOpacity = _getCssOpacity(circle.strokeColor) - ..strokeWeight = circle.strokeWidth - ..fillColor = _getCssColor(circle.fillColor) - ..fillOpacity = _getCssOpacity(circle.fillColor) - ..center = gmaps.LatLng(circle.center.latitude, circle.center.longitude) - ..radius = circle.radius - ..visible = circle.visible; - return populationOptions; -} - -gmaps.PolygonOptions _polygonOptionsFromPolygon( - gmaps.GMap googleMap, Polygon polygon) { - List path = []; - polygon.points.forEach((point) { - path.add(_latLngToGmLatLng(point)); - }); - final polygonDirection = _isPolygonClockwise(path); - List> paths = [path]; - int holeIndex = 0; - polygon.holes.forEach((hole) { - List holePath = - hole.map((point) => _latLngToGmLatLng(point)).toList(); - if (_isPolygonClockwise(holePath) == polygonDirection) { - holePath = holePath.reversed.toList(); - if (kDebugMode) { - print( - 'Hole [$holeIndex] in Polygon [${polygon.polygonId.value}] has been reversed.' - ' Ensure holes in polygons are "wound in the opposite direction to the outer path."' - ' More info: https://github.com/flutter/flutter/issues/74096'); - } - } - paths.add(holePath); - holeIndex++; - }); - return gmaps.PolygonOptions() - ..paths = paths - ..strokeColor = _getCssColor(polygon.strokeColor) - ..strokeOpacity = _getCssOpacity(polygon.strokeColor) - ..strokeWeight = polygon.strokeWidth - ..fillColor = _getCssColor(polygon.fillColor) - ..fillOpacity = _getCssOpacity(polygon.fillColor) - ..visible = polygon.visible - ..zIndex = polygon.zIndex - ..geodesic = polygon.geodesic; -} - -/// Calculates the direction of a given Polygon -/// based on: https://stackoverflow.com/a/1165943 -/// -/// returns [true] if clockwise [false] if counterclockwise -/// -/// This method expects that the incoming [path] is a `List` of well-formed, -/// non-null [gmaps.LatLng] objects. -/// -/// Currently, this method is only called from [_polygonOptionsFromPolygon], and -/// the `path` is a transformed version of [Polygon.points] or each of the -/// [Polygon.holes], guaranteeing that `lat` and `lng` can be accessed with `!`. -bool _isPolygonClockwise(List path) { - var direction = 0.0; - for (var i = 0; i < path.length; i++) { - direction = direction + - ((path[(i + 1) % path.length].lat - path[i].lat) * - (path[(i + 1) % path.length].lng + path[i].lng)); - } - return direction >= 0; -} - -gmaps.PolylineOptions _polylineOptionsFromPolyline( - gmaps.GMap googleMap, Polyline polyline) { - List paths = []; - polyline.points.forEach((point) { - paths.add(_latLngToGmLatLng(point)); - }); - - return gmaps.PolylineOptions() - ..path = paths - ..strokeWeight = polyline.width - ..strokeColor = _getCssColor(polyline.color) - ..strokeOpacity = _getCssOpacity(polyline.color) - ..visible = polyline.visible - ..zIndex = polyline.zIndex - ..geodesic = polyline.geodesic; -// this.endCap = Cap.buttCap, -// this.jointType = JointType.mitered, -// this.patterns = const [], -// this.startCap = Cap.buttCap, -// this.width = 10, -} - -// Translates a [CameraUpdate] into operations on a [gmaps.GMap]. -void _applyCameraUpdate(gmaps.GMap map, CameraUpdate update) { - final json = update.toJson() as List; - switch (json[0]) { - case 'newCameraPosition': - map.heading = json[1]['bearing']; - map.zoom = json[1]['zoom']; - map.panTo(gmaps.LatLng(json[1]['target'][0], json[1]['target'][1])); - map.tilt = json[1]['tilt']; - break; - case 'newLatLng': - map.panTo(gmaps.LatLng(json[1][0], json[1][1])); - break; - case 'newLatLngZoom': - map.zoom = json[2]; - map.panTo(gmaps.LatLng(json[1][0], json[1][1])); - break; - case 'newLatLngBounds': - map.fitBounds(gmaps.LatLngBounds( - gmaps.LatLng(json[1][0][0], json[1][0][1]), - gmaps.LatLng(json[1][1][0], json[1][1][1]))); - // padding = json[2]; - // Needs package:google_maps ^4.0.0 to adjust the padding in fitBounds - break; - case 'scrollBy': - map.panBy(json[1], json[2]); - break; - case 'zoomBy': - gmaps.LatLng? focusLatLng; - double zoomDelta = json[1] ?? 0; - // Web only supports integer changes... - int newZoomDelta = zoomDelta < 0 ? zoomDelta.floor() : zoomDelta.ceil(); - if (json.length == 3) { - // With focus - try { - focusLatLng = _pixelToLatLng(map, json[2][0], json[2][1]); - } catch (e) { - // https://github.com/a14n/dart-google-maps/issues/87 - // print('Error computing new focus LatLng. JS Error: ' + e.toString()); - } - } - map.zoom = (map.zoom ?? 0) + newZoomDelta; - if (focusLatLng != null) { - map.panTo(focusLatLng); - } - break; - case 'zoomIn': - map.zoom = (map.zoom ?? 0) + 1; - break; - case 'zoomOut': - map.zoom = (map.zoom ?? 0) - 1; - break; - case 'zoomTo': - map.zoom = json[1]; - break; - default: - throw UnimplementedError('Unimplemented CameraMove: ${json[0]}.'); - } -} - -// original JS by: Byron Singh (https://stackoverflow.com/a/30541162) -gmaps.LatLng _pixelToLatLng(gmaps.GMap map, int x, int y) { - final bounds = map.bounds; - final projection = map.projection; - final zoom = map.zoom; - - assert( - bounds != null, 'Map Bounds required to compute LatLng of screen x/y.'); - assert(projection != null, - 'Map Projection required to compute LatLng of screen x/y'); - assert(zoom != null, - 'Current map zoom level required to compute LatLng of screen x/y'); - - final ne = bounds!.northEast; - final sw = bounds.southWest; - - final topRight = projection!.fromLatLngToPoint!(ne)!; - final bottomLeft = projection.fromLatLngToPoint!(sw)!; - - final scale = 1 << (zoom!.toInt()); // 2 ^ zoom - - final point = - gmaps.Point((x / scale) + bottomLeft.x!, (y / scale) + topRight.y!); - - return projection.fromPointToLatLng!(point)!; -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart deleted file mode 100644 index 226268270579..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_controller.dart +++ /dev/null @@ -1,405 +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. - -part of google_maps_flutter_web; - -/// Type used when passing an override to the _createMap function. -@visibleForTesting -typedef DebugCreateMapFunction = gmaps.GMap Function( - HtmlElement div, gmaps.MapOptions options); - -/// Encapsulates a [gmaps.GMap], its events, and where in the DOM it's rendered. -class GoogleMapController { - // The internal ID of the map. Used to broadcast events, DOM IDs and everything where a unique ID is needed. - final int _mapId; - - final CameraPosition _initialCameraPosition; - final Set _markers; - final Set _polygons; - final Set _polylines; - final Set _circles; - // The raw options passed by the user, before converting to gmaps. - // Caching this allows us to re-create the map faithfully when needed. - Map _rawMapOptions = {}; - - // Creates the 'viewType' for the _widget - String _getViewType(int mapId) => 'plugins.flutter.io/google_maps_$mapId'; - - // The Flutter widget that contains the rendered Map. - HtmlElementView? _widget; - late HtmlElement _div; - - /// The Flutter widget that will contain the rendered Map. Used for caching. - Widget? get widget { - if (_widget == null && !_streamController.isClosed) { - _widget = HtmlElementView( - viewType: _getViewType(_mapId), - ); - } - return _widget; - } - - // The currently-enabled traffic layer. - gmaps.TrafficLayer? _trafficLayer; - - /// A getter for the current traffic layer. Only for tests. - @visibleForTesting - gmaps.TrafficLayer? get trafficLayer => _trafficLayer; - - // The underlying GMap instance. This is the interface with the JS SDK. - gmaps.GMap? _googleMap; - - // The StreamController used by this controller and the geometry ones. - final StreamController _streamController; - - /// The Stream over which this controller broadcasts events. - Stream get events => _streamController.stream; - - // Geometry controllers, for different features of the map. - CirclesController? _circlesController; - PolygonsController? _polygonsController; - PolylinesController? _polylinesController; - MarkersController? _markersController; - // Keeps track if _attachGeometryControllers has been called or not. - bool _controllersBoundToMap = false; - - // Keeps track if the map is moving or not. - bool _mapIsMoving = false; - - /// Initializes the GMap, and the sub-controllers related to it. Wires events. - GoogleMapController({ - required int mapId, - required StreamController streamController, - required CameraPosition initialCameraPosition, - Set markers = const {}, - Set polygons = const {}, - Set polylines = const {}, - Set circles = const {}, - Set tileOverlays = const {}, - Set> gestureRecognizers = - const >{}, - Map mapOptions = const {}, - }) : _mapId = mapId, - _streamController = streamController, - _initialCameraPosition = initialCameraPosition, - _markers = markers, - _polygons = polygons, - _polylines = polylines, - _circles = circles, - _rawMapOptions = mapOptions { - _circlesController = CirclesController(stream: this._streamController); - _polygonsController = PolygonsController(stream: this._streamController); - _polylinesController = PolylinesController(stream: this._streamController); - _markersController = MarkersController(stream: this._streamController); - - // Register the view factory that will hold the `_div` that holds the map in the DOM. - // The `_div` needs to be created outside of the ViewFactory (and cached!) so we can - // use it to create the [gmaps.GMap] in the `init()` method of this class. - _div = DivElement() - ..id = _getViewType(mapId) - ..style.width = '100%' - ..style.height = '100%'; - - ui.platformViewRegistry.registerViewFactory( - _getViewType(mapId), - (int viewId) => _div, - ); - } - - /// Overrides certain properties to install mocks defined during testing. - @visibleForTesting - void debugSetOverrides({ - DebugCreateMapFunction? createMap, - MarkersController? markers, - CirclesController? circles, - PolygonsController? polygons, - PolylinesController? polylines, - }) { - _overrideCreateMap = createMap; - _markersController = markers ?? _markersController; - _circlesController = circles ?? _circlesController; - _polygonsController = polygons ?? _polygonsController; - _polylinesController = polylines ?? _polylinesController; - } - - DebugCreateMapFunction? _overrideCreateMap; - - gmaps.GMap _createMap(HtmlElement div, gmaps.MapOptions options) { - if (_overrideCreateMap != null) { - return _overrideCreateMap!(div, options); - } - return gmaps.GMap(div, options); - } - - /// Initializes the [gmaps.GMap] instance from the stored `rawOptions`. - /// - /// This method actually renders the GMap into the cached `_div`. This is - /// called by the [GoogleMapsPlugin.init] method when appropriate. - /// - /// Failure to call this method would result in the GMap not rendering at all, - /// and most of the public methods on this class no-op'ing. - void init() { - var options = _rawOptionsToGmapsOptions(_rawMapOptions); - // Initial position can only to be set here! - options = _applyInitialPosition(_initialCameraPosition, options); - - // Create the map... - final map = _createMap(_div, options); - _googleMap = map; - - _attachMapEvents(map); - _attachGeometryControllers(map); - - _renderInitialGeometry( - markers: _markers, - circles: _circles, - polygons: _polygons, - polylines: _polylines, - ); - - _setTrafficLayer(map, _isTrafficLayerEnabled(_rawMapOptions)); - } - - // Funnels map gmap events into the plugin's stream controller. - void _attachMapEvents(gmaps.GMap map) { - map.onClick.listen((event) { - assert(event.latLng != null); - _streamController.add( - MapTapEvent(_mapId, _gmLatLngToLatLng(event.latLng!)), - ); - }); - map.onRightclick.listen((event) { - assert(event.latLng != null); - _streamController.add( - MapLongPressEvent(_mapId, _gmLatLngToLatLng(event.latLng!)), - ); - }); - map.onBoundsChanged.listen((event) { - if (!_mapIsMoving) { - _mapIsMoving = true; - _streamController.add(CameraMoveStartedEvent(_mapId)); - } - _streamController.add( - CameraMoveEvent(_mapId, _gmViewportToCameraPosition(map)), - ); - }); - map.onIdle.listen((event) { - _mapIsMoving = false; - _streamController.add(CameraIdleEvent(_mapId)); - }); - } - - // Binds the Geometry controllers to a map instance - void _attachGeometryControllers(gmaps.GMap map) { - // Now we can add the initial geometry. - // And bind the (ready) map instance to the other geometry controllers. - // - // These controllers are either created in the constructor of this class, or - // overriden (for testing) by the [debugSetOverrides] method. They can't be - // null. - assert(_circlesController != null, - 'Cannot attach a map to a null CirclesController instance.'); - assert(_polygonsController != null, - 'Cannot attach a map to a null PolygonsController instance.'); - assert(_polylinesController != null, - 'Cannot attach a map to a null PolylinesController instance.'); - assert(_markersController != null, - 'Cannot attach a map to a null MarkersController instance.'); - - _circlesController!.bindToMap(_mapId, map); - _polygonsController!.bindToMap(_mapId, map); - _polylinesController!.bindToMap(_mapId, map); - _markersController!.bindToMap(_mapId, map); - - _controllersBoundToMap = true; - } - - // Renders the initial sets of geometry. - void _renderInitialGeometry({ - Set markers = const {}, - Set circles = const {}, - Set polygons = const {}, - Set polylines = const {}, - }) { - assert( - _controllersBoundToMap, - 'Geometry controllers must be bound to a map before any geometry can ' + - 'be added to them. Ensure _attachGeometryControllers is called first.'); - - // The above assert will only succeed if the controllers have been bound to a map - // in the [_attachGeometryControllers] method, which ensures that all these - // controllers below are *not* null. - - _markersController!.addMarkers(markers); - _circlesController!.addCircles(circles); - _polygonsController!.addPolygons(polygons); - _polylinesController!.addPolylines(polylines); - } - - // Merges new options coming from the plugin into the _rawMapOptions map. - // - // Returns the updated _rawMapOptions object. - Map _mergeRawOptions(Map newOptions) { - _rawMapOptions = { - ..._rawMapOptions, - ...newOptions, - }; - return _rawMapOptions; - } - - /// Updates the map options from a `Map`. - /// - /// This method converts the map into the proper [gmaps.MapOptions] - void updateRawOptions(Map optionsUpdate) { - assert(_googleMap != null, 'Cannot update options on a null map.'); - - final newOptions = _mergeRawOptions(optionsUpdate); - - _setOptions(_rawOptionsToGmapsOptions(newOptions)); - _setTrafficLayer(_googleMap!, _isTrafficLayerEnabled(newOptions)); - } - - // Sets new [gmaps.MapOptions] on the wrapped map. - void _setOptions(gmaps.MapOptions options) { - _googleMap?.options = options; - } - - // Attaches/detaches a Traffic Layer on the passed `map` if `attach` is true/false. - void _setTrafficLayer(gmaps.GMap map, bool attach) { - if (attach && _trafficLayer == null) { - _trafficLayer = gmaps.TrafficLayer()..set('map', map); - } - if (!attach && _trafficLayer != null) { - _trafficLayer!.set('map', null); - _trafficLayer = null; - } - } - - // _googleMap manipulation - // Viewport - - /// Returns the [LatLngBounds] of the current viewport. - Future getVisibleRegion() async { - assert(_googleMap != null, 'Cannot get the visible region of a null map.'); - - return _gmLatLngBoundsTolatLngBounds( - await _googleMap!.bounds ?? _nullGmapsLatLngBounds, - ); - } - - /// Returns the [ScreenCoordinate] for a given viewport [LatLng]. - Future getScreenCoordinate(LatLng latLng) async { - assert(_googleMap != null, - 'Cannot get the screen coordinates with a null map.'); - assert(_googleMap!.projection != null, - 'Cannot compute screen coordinate with a null map or projection.'); - - final point = - _googleMap!.projection!.fromLatLngToPoint!(_latLngToGmLatLng(latLng))!; - - assert(point.x != null && point.y != null, - 'The x and y of a ScreenCoordinate cannot be null.'); - - return ScreenCoordinate(x: point.x!.toInt(), y: point.y!.toInt()); - } - - /// Returns the [LatLng] for a `screenCoordinate` (in pixels) of the viewport. - Future getLatLng(ScreenCoordinate screenCoordinate) async { - assert(_googleMap != null, - 'Cannot get the lat, lng of a screen coordinate with a null map.'); - - final gmaps.LatLng latLng = - _pixelToLatLng(_googleMap!, screenCoordinate.x, screenCoordinate.y); - return _gmLatLngToLatLng(latLng); - } - - /// Applies a `cameraUpdate` to the current viewport. - Future moveCamera(CameraUpdate cameraUpdate) async { - assert(_googleMap != null, 'Cannot update the camera of a null map.'); - - return _applyCameraUpdate(_googleMap!, cameraUpdate); - } - - /// Returns the zoom level of the current viewport. - Future getZoomLevel() async { - assert(_googleMap != null, 'Cannot get zoom level of a null map.'); - assert(_googleMap!.zoom != null, - 'Zoom level should not be null. Is the map correctly initialized?'); - - return _googleMap!.zoom!.toDouble(); - } - - // Geometry manipulation - - /// Applies [CircleUpdates] to the currently managed circles. - void updateCircles(CircleUpdates updates) { - assert( - _circlesController != null, 'Cannot update circles after dispose().'); - _circlesController?.addCircles(updates.circlesToAdd); - _circlesController?.changeCircles(updates.circlesToChange); - _circlesController?.removeCircles(updates.circleIdsToRemove); - } - - /// Applies [PolygonUpdates] to the currently managed polygons. - void updatePolygons(PolygonUpdates updates) { - assert( - _polygonsController != null, 'Cannot update polygons after dispose().'); - _polygonsController?.addPolygons(updates.polygonsToAdd); - _polygonsController?.changePolygons(updates.polygonsToChange); - _polygonsController?.removePolygons(updates.polygonIdsToRemove); - } - - /// Applies [PolylineUpdates] to the currently managed lines. - void updatePolylines(PolylineUpdates updates) { - assert(_polylinesController != null, - 'Cannot update polylines after dispose().'); - _polylinesController?.addPolylines(updates.polylinesToAdd); - _polylinesController?.changePolylines(updates.polylinesToChange); - _polylinesController?.removePolylines(updates.polylineIdsToRemove); - } - - /// Applies [MarkerUpdates] to the currently managed markers. - void updateMarkers(MarkerUpdates updates) { - assert( - _markersController != null, 'Cannot update markers after dispose().'); - _markersController?.addMarkers(updates.markersToAdd); - _markersController?.changeMarkers(updates.markersToChange); - _markersController?.removeMarkers(updates.markerIdsToRemove); - } - - /// Shows the [InfoWindow] of the marker identified by its [MarkerId]. - void showInfoWindow(MarkerId markerId) { - assert(_markersController != null, - 'Cannot show infowindow of marker [${markerId.value}] after dispose().'); - _markersController?.showMarkerInfoWindow(markerId); - } - - /// Hides the [InfoWindow] of the marker identified by its [MarkerId]. - void hideInfoWindow(MarkerId markerId) { - assert(_markersController != null, - 'Cannot hide infowindow of marker [${markerId.value}] after dispose().'); - _markersController?.hideMarkerInfoWindow(markerId); - } - - /// Returns true if the [InfoWindow] of the marker identified by [MarkerId] is shown. - bool isInfoWindowShown(MarkerId markerId) { - return _markersController?.isInfoWindowShown(markerId) ?? false; - } - - // Cleanup - - /// Disposes of this controller and its resources. - /// - /// You won't be able to call many of the methods on this controller after - /// calling `dispose`! - void dispose() { - _widget = null; - _googleMap = null; - _circlesController = null; - _polygonsController = null; - _polylinesController = null; - _markersController = null; - _streamController.close(); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart deleted file mode 100644 index 692917fef4da..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/google_maps_flutter_web.dart +++ /dev/null @@ -1,319 +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. - -part of google_maps_flutter_web; - -/// The web implementation of [GoogleMapsFlutterPlatform]. -/// -/// This class implements the `package:google_maps_flutter` functionality for the web. -class GoogleMapsPlugin extends GoogleMapsFlutterPlatform { - /// Registers this class as the default instance of [GoogleMapsFlutterPlatform]. - static void registerWith(Registrar registrar) { - GoogleMapsFlutterPlatform.instance = GoogleMapsPlugin(); - } - - // A cache of map controllers by map Id. - Map _mapById = Map(); - - /// Allows tests to inject controllers without going through the buildView flow. - @visibleForTesting - void debugSetMapById(Map mapById) { - _mapById = mapById; - } - - // Convenience getter for a stream of events filtered by their mapId. - Stream _events(int mapId) => _map(mapId).events; - - // Convenience getter for a map controller by its mapId. - GoogleMapController _map(int mapId) { - final controller = _mapById[mapId]; - assert(controller != null, - 'Maps cannot be retrieved before calling buildView!'); - return controller; - } - - @override - Future init(int mapId) async { - _map(mapId).init(); - } - - /// Updates the options of a given `mapId`. - /// - /// This attempts to merge the new `optionsUpdate` passed in, with the previous - /// options passed to the map (in other updates, or when creating it). - @override - Future updateMapOptions( - Map optionsUpdate, { - required int mapId, - }) async { - _map(mapId).updateRawOptions(optionsUpdate); - } - - /// Applies the passed in `markerUpdates` to the `mapId`. - @override - Future updateMarkers( - MarkerUpdates markerUpdates, { - required int mapId, - }) async { - _map(mapId).updateMarkers(markerUpdates); - } - - /// Applies the passed in `polygonUpdates` to the `mapId`. - @override - Future updatePolygons( - PolygonUpdates polygonUpdates, { - required int mapId, - }) async { - _map(mapId).updatePolygons(polygonUpdates); - } - - /// Applies the passed in `polylineUpdates` to the `mapId`. - @override - Future updatePolylines( - PolylineUpdates polylineUpdates, { - required int mapId, - }) async { - _map(mapId).updatePolylines(polylineUpdates); - } - - /// Applies the passed in `circleUpdates` to the `mapId`. - @override - Future updateCircles( - CircleUpdates circleUpdates, { - required int mapId, - }) async { - _map(mapId).updateCircles(circleUpdates); - } - - @override - Future updateTileOverlays({ - required Set newTileOverlays, - required int mapId, - }) async { - return; // Noop for now! - } - - @override - Future clearTileCache( - TileOverlayId tileOverlayId, { - required int mapId, - }) async { - return; // Noop for now! - } - - /// Applies the given `cameraUpdate` to the current viewport (with animation). - @override - Future animateCamera( - CameraUpdate cameraUpdate, { - required int mapId, - }) async { - return moveCamera(cameraUpdate, mapId: mapId); - } - - /// Applies the given `cameraUpdate` to the current viewport. - @override - Future moveCamera( - CameraUpdate cameraUpdate, { - required int mapId, - }) async { - return _map(mapId).moveCamera(cameraUpdate); - } - - /// Sets the passed-in `mapStyle` to the map. - /// - /// This function just adds a 'styles' option to the current map options. - /// - /// Subsequent calls to this method override previous calls, you need to - /// pass full styles. - @override - Future setMapStyle( - String? mapStyle, { - required int mapId, - }) async { - _map(mapId).updateRawOptions({ - 'styles': _mapStyles(mapStyle), - }); - } - - /// Returns the bounds of the current viewport. - @override - Future getVisibleRegion({ - required int mapId, - }) { - return _map(mapId).getVisibleRegion(); - } - - /// Returns the screen coordinate (in pixels) of a given `latLng`. - @override - Future getScreenCoordinate( - LatLng latLng, { - required int mapId, - }) { - return _map(mapId).getScreenCoordinate(latLng); - } - - /// Returns the [LatLng] of a [ScreenCoordinate] of the viewport. - @override - Future getLatLng( - ScreenCoordinate screenCoordinate, { - required int mapId, - }) { - return _map(mapId).getLatLng(screenCoordinate); - } - - /// Shows the [InfoWindow] (if any) of the [Marker] identified by `markerId`. - /// - /// See also: - /// * [hideMarkerInfoWindow] to hide the info window. - /// * [isMarkerInfoWindowShown] to check if the info window is visible/hidden. - @override - Future showMarkerInfoWindow( - MarkerId markerId, { - required int mapId, - }) async { - _map(mapId).showInfoWindow(markerId); - } - - /// Hides the [InfoWindow] (if any) of the [Marker] identified by `markerId`. - /// - /// See also: - /// * [showMarkerInfoWindow] to show the info window. - /// * [isMarkerInfoWindowShown] to check if the info window is shown. - @override - Future hideMarkerInfoWindow( - MarkerId markerId, { - required int mapId, - }) async { - _map(mapId).hideInfoWindow(markerId); - } - - /// Returns true if the [InfoWindow] of the [Marker] identified by `markerId` is shown. - /// - /// See also: - /// * [showMarkerInfoWindow] to show the info window. - /// * [hideMarkerInfoWindow] to hide the info window. - @override - Future isMarkerInfoWindowShown( - MarkerId markerId, { - required int mapId, - }) async { - return _map(mapId).isInfoWindowShown(markerId); - } - - /// Returns the zoom level of the `mapId`. - @override - Future getZoomLevel({ - required int mapId, - }) { - return _map(mapId).getZoomLevel(); - } - - // The following are the 11 possible streams of data from the native side - // into the plugin - - @override - Stream onCameraMoveStarted({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onCameraMove({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onCameraIdle({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onMarkerTap({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onInfoWindowTap({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onMarkerDragEnd({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onPolylineTap({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onPolygonTap({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onCircleTap({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onTap({required int mapId}) { - return _events(mapId).whereType(); - } - - @override - Stream onLongPress({required int mapId}) { - return _events(mapId).whereType(); - } - - /// Disposes of the current map. It can't be used afterwards! - @override - void dispose({required int mapId}) { - _map(mapId).dispose(); - _mapById.remove(mapId); - } - - @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 {}, - }) { - // Bail fast if we've already rendered this map ID... - if (_mapById[creationId]?.widget != null) { - return _mapById[creationId].widget; - } - - final StreamController controller = - StreamController.broadcast(); - - final mapController = GoogleMapController( - initialCameraPosition: initialCameraPosition, - mapId: creationId, - streamController: controller, - markers: markers, - polygons: polygons, - polylines: polylines, - circles: circles, - mapOptions: mapOptions, - ); - - _mapById[creationId] = mapController; - - onPlatformViewCreated.call(creationId); - - assert(mapController.widget != null, - 'The widget of a GoogleMapController cannot be null before calling dispose on it.'); - - return mapController.widget!; - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart deleted file mode 100644 index 5b0169b565e5..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/marker.dart +++ /dev/null @@ -1,100 +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. - -part of google_maps_flutter_web; - -/// The `MarkerController` class wraps a [gmaps.Marker], how it handles events, and its associated (optional) [gmaps.InfoWindow] widget. -class MarkerController { - gmaps.Marker? _marker; - - final bool _consumeTapEvents; - - final gmaps.InfoWindow? _infoWindow; - - bool _infoWindowShown = false; - - /// Creates a `MarkerController`, which wraps a [gmaps.Marker] object, its `onTap`/`onDrag` behavior, and its associated [gmaps.InfoWindow]. - MarkerController({ - required gmaps.Marker marker, - gmaps.InfoWindow? infoWindow, - bool consumeTapEvents = false, - LatLngCallback? onDragEnd, - ui.VoidCallback? onTap, - }) : _marker = marker, - _infoWindow = infoWindow, - _consumeTapEvents = consumeTapEvents { - if (onTap != null) { - marker.onClick.listen((event) { - onTap.call(); - }); - } - if (onDragEnd != null) { - marker.onDragend.listen((event) { - if (marker != null) { - marker.position = event.latLng; - } - onDragEnd.call(event.latLng ?? _nullGmapsLatLng); - }); - } - } - - /// Returns `true` if this Controller will use its own `onTap` handler to consume events. - bool get consumeTapEvents => _consumeTapEvents; - - /// Returns `true` if the [gmaps.InfoWindow] associated to this marker is being shown. - bool get infoWindowShown => _infoWindowShown; - - /// Returns the [gmaps.Marker] associated to this controller. - gmaps.Marker? get marker => _marker; - - /// Returns the [gmaps.InfoWindow] associated to the marker. - @visibleForTesting - gmaps.InfoWindow? get infoWindow => _infoWindow; - - /// Updates the options of the wrapped [gmaps.Marker] object. - /// - /// This cannot be called after [remove]. - void update( - gmaps.MarkerOptions options, { - HtmlElement? newInfoWindowContent, - }) { - assert(_marker != null, 'Cannot `update` Marker after calling `remove`.'); - _marker!.options = options; - if (_infoWindow != null && newInfoWindowContent != null) { - _infoWindow!.content = newInfoWindowContent; - } - } - - /// Disposes of the currently wrapped [gmaps.Marker]. - void remove() { - if (_marker != null) { - _infoWindowShown = false; - _marker!.visible = false; - _marker!.map = null; - _marker = null; - } - } - - /// Hide the associated [gmaps.InfoWindow]. - /// - /// This cannot be called after [remove]. - void hideInfoWindow() { - assert(_marker != null, 'Cannot `hideInfoWindow` on a `remove`d Marker.'); - if (_infoWindow != null) { - _infoWindow!.close(); - _infoWindowShown = false; - } - } - - /// Show the associated [gmaps.InfoWindow]. - /// - /// This cannot be called after [remove]. - void showInfoWindow() { - assert(_marker != null, 'Cannot `showInfoWindow` on a `remove`d Marker.'); - if (_infoWindow != null) { - _infoWindow!.open(_marker!.map, _marker); - _infoWindowShown = true; - } - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart deleted file mode 100644 index b650b9bcf1c8..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/markers.dart +++ /dev/null @@ -1,157 +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. - -part of google_maps_flutter_web; - -/// This class manages a set of [MarkerController]s associated to a [GoogleMapController]. -class MarkersController extends GeometryController { - // A cache of [MarkerController]s indexed by their [MarkerId]. - final Map _markerIdToController; - - // The stream over which markers broadcast their events - StreamController _streamController; - - /// Initialize the cache. The [StreamController] comes from the [GoogleMapController], and is shared with other controllers. - MarkersController({ - required StreamController stream, - }) : _streamController = stream, - _markerIdToController = Map(); - - /// Returns the cache of [MarkerController]s. Test only. - @visibleForTesting - Map get markers => _markerIdToController; - - /// Adds a set of [Marker] objects to the cache. - /// - /// Wraps each [Marker] into its corresponding [MarkerController]. - void addMarkers(Set markersToAdd) { - markersToAdd.forEach(_addMarker); - } - - void _addMarker(Marker marker) { - if (marker == null) { - return; - } - - final infoWindowOptions = _infoWindowOptionsFromMarker(marker); - gmaps.InfoWindow? gmInfoWindow; - - if (infoWindowOptions != null) { - gmInfoWindow = gmaps.InfoWindow(infoWindowOptions); - // Google Maps' JS SDK does not have a click event on the InfoWindow, so - // we make one... - if (infoWindowOptions.content is HtmlElement) { - final content = infoWindowOptions.content as HtmlElement; - content.onClick.listen((_) { - _onInfoWindowTap(marker.markerId); - }); - } - } - - final currentMarker = _markerIdToController[marker.markerId]?.marker; - - final populationOptions = _markerOptionsFromMarker(marker, currentMarker); - gmaps.Marker gmMarker = gmaps.Marker(populationOptions); - gmMarker.map = googleMap; - MarkerController controller = MarkerController( - marker: gmMarker, - infoWindow: gmInfoWindow, - consumeTapEvents: marker.consumeTapEvents, - onTap: () { - this.showMarkerInfoWindow(marker.markerId); - _onMarkerTap(marker.markerId); - }, - onDragEnd: (gmaps.LatLng latLng) { - _onMarkerDragEnd(marker.markerId, latLng); - }, - ); - _markerIdToController[marker.markerId] = controller; - } - - /// Updates a set of [Marker] objects with new options. - void changeMarkers(Set markersToChange) { - markersToChange.forEach(_changeMarker); - } - - void _changeMarker(Marker marker) { - MarkerController? markerController = _markerIdToController[marker.markerId]; - if (markerController != null) { - final markerOptions = _markerOptionsFromMarker( - marker, - markerController.marker, - ); - final infoWindow = _infoWindowOptionsFromMarker(marker); - markerController.update( - markerOptions, - newInfoWindowContent: infoWindow?.content as HtmlElement?, - ); - } - } - - /// Removes a set of [MarkerId]s from the cache. - void removeMarkers(Set markerIdsToRemove) { - markerIdsToRemove.forEach(_removeMarker); - } - - void _removeMarker(MarkerId markerId) { - final MarkerController? markerController = _markerIdToController[markerId]; - markerController?.remove(); - _markerIdToController.remove(markerId); - } - - // InfoWindow... - - /// Shows the [InfoWindow] of a [MarkerId]. - /// - /// See also [hideMarkerInfoWindow] and [isInfoWindowShown]. - void showMarkerInfoWindow(MarkerId markerId) { - _hideAllMarkerInfoWindow(); - MarkerController? markerController = _markerIdToController[markerId]; - markerController?.showInfoWindow(); - } - - /// Hides the [InfoWindow] of a [MarkerId]. - /// - /// See also [showMarkerInfoWindow] and [isInfoWindowShown]. - void hideMarkerInfoWindow(MarkerId markerId) { - MarkerController? markerController = _markerIdToController[markerId]; - markerController?.hideInfoWindow(); - } - - /// Returns whether or not the [InfoWindow] of a [MarkerId] is shown. - /// - /// See also [showMarkerInfoWindow] and [hideMarkerInfoWindow]. - bool isInfoWindowShown(MarkerId markerId) { - MarkerController? markerController = _markerIdToController[markerId]; - return markerController?.infoWindowShown ?? false; - } - - // Handle internal events - - bool _onMarkerTap(MarkerId markerId) { - // Have you ended here on your debugging? Is this wrong? - // Comment here: https://github.com/flutter/flutter/issues/64084 - _streamController.add(MarkerTapEvent(mapId, markerId)); - return _markerIdToController[markerId]?.consumeTapEvents ?? false; - } - - void _onInfoWindowTap(MarkerId markerId) { - _streamController.add(InfoWindowTapEvent(mapId, markerId)); - } - - void _onMarkerDragEnd(MarkerId markerId, gmaps.LatLng latLng) { - _streamController.add(MarkerDragEndEvent( - mapId, - _gmLatLngToLatLng(latLng), - markerId, - )); - } - - void _hideAllMarkerInfoWindow() { - _markerIdToController.values - .where((controller) => - controller == null ? false : controller.infoWindowShown) - .forEach((controller) => controller.hideInfoWindow()); - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygon.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygon.dart deleted file mode 100644 index 9921d2ff3876..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygon.dart +++ /dev/null @@ -1,50 +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. - -part of google_maps_flutter_web; - -/// The `PolygonController` class wraps a [gmaps.Polygon] and its `onTap` behavior. -class PolygonController { - gmaps.Polygon? _polygon; - - final bool _consumeTapEvents; - - /// Creates a `PolygonController` that wraps a [gmaps.Polygon] object and its `onTap` behavior. - PolygonController({ - required gmaps.Polygon polygon, - bool consumeTapEvents = false, - ui.VoidCallback? onTap, - }) : _polygon = polygon, - _consumeTapEvents = consumeTapEvents { - if (onTap != null) { - polygon.onClick.listen((event) { - onTap.call(); - }); - } - } - - /// Returns the wrapped [gmaps.Polygon]. Only used for testing. - @visibleForTesting - gmaps.Polygon? get polygon => _polygon; - - /// Returns `true` if this Controller will use its own `onTap` handler to consume events. - bool get consumeTapEvents => _consumeTapEvents; - - /// Updates the options of the wrapped [gmaps.Polygon] object. - /// - /// This cannot be called after [remove]. - void update(gmaps.PolygonOptions options) { - assert(_polygon != null, 'Cannot `update` Polygon after calling `remove`.'); - _polygon!.options = options; - } - - /// Disposes of the currently wrapped [gmaps.Polygon]. - void remove() { - if (_polygon != null) { - _polygon!.visible = false; - _polygon!.map = null; - _polygon = null; - } - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygons.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygons.dart deleted file mode 100644 index 8a9643156351..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polygons.dart +++ /dev/null @@ -1,85 +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. - -part of google_maps_flutter_web; - -/// This class manages a set of [PolygonController]s associated to a [GoogleMapController]. -class PolygonsController extends GeometryController { - // A cache of [PolygonController]s indexed by their [PolygonId]. - final Map _polygonIdToController; - - // The stream over which polygons broadcast events - StreamController _streamController; - - /// Initializes the cache. The [StreamController] comes from the [GoogleMapController], and is shared with other controllers. - PolygonsController({ - required StreamController stream, - }) : _streamController = stream, - _polygonIdToController = Map(); - - /// Returns the cache of [PolygonController]s. Test only. - @visibleForTesting - Map get polygons => _polygonIdToController; - - /// Adds a set of [Polygon] objects to the cache. - /// - /// Wraps each Polygon into its corresponding [PolygonController]. - void addPolygons(Set polygonsToAdd) { - if (polygonsToAdd != null) { - polygonsToAdd.forEach((polygon) { - _addPolygon(polygon); - }); - } - } - - void _addPolygon(Polygon polygon) { - if (polygon == null) { - return; - } - - final populationOptions = _polygonOptionsFromPolygon(googleMap, polygon); - gmaps.Polygon gmPolygon = gmaps.Polygon(populationOptions); - gmPolygon.map = googleMap; - PolygonController controller = PolygonController( - polygon: gmPolygon, - consumeTapEvents: polygon.consumeTapEvents, - onTap: () { - _onPolygonTap(polygon.polygonId); - }); - _polygonIdToController[polygon.polygonId] = controller; - } - - /// Updates a set of [Polygon] objects with new options. - void changePolygons(Set polygonsToChange) { - if (polygonsToChange != null) { - polygonsToChange.forEach((polygonToChange) { - _changePolygon(polygonToChange); - }); - } - } - - void _changePolygon(Polygon polygon) { - PolygonController? polygonController = - _polygonIdToController[polygon.polygonId]; - polygonController?.update(_polygonOptionsFromPolygon(googleMap, polygon)); - } - - /// Removes a set of [PolygonId]s from the cache. - void removePolygons(Set polygonIdsToRemove) { - polygonIdsToRemove.forEach((polygonId) { - final PolygonController? polygonController = - _polygonIdToController[polygonId]; - polygonController?.remove(); - _polygonIdToController.remove(polygonId); - }); - } - - // Handle internal events - bool _onPolygonTap(PolygonId polygonId) { - // Have you ended here on your debugging? Is this wrong? - // Comment here: https://github.com/flutter/flutter/issues/64084 - _streamController.add(PolygonTapEvent(mapId, polygonId)); - return _polygonIdToController[polygonId]?.consumeTapEvents ?? false; - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polyline.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polyline.dart deleted file mode 100644 index eb4b6d88b503..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polyline.dart +++ /dev/null @@ -1,51 +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. - -part of google_maps_flutter_web; - -/// The `PolygonController` class wraps a [gmaps.Polyline] and its `onTap` behavior. -class PolylineController { - gmaps.Polyline? _polyline; - - final bool _consumeTapEvents; - - /// Creates a `PolylineController` that wraps a [gmaps.Polyline] object and its `onTap` behavior. - PolylineController({ - required gmaps.Polyline polyline, - bool consumeTapEvents = false, - ui.VoidCallback? onTap, - }) : _polyline = polyline, - _consumeTapEvents = consumeTapEvents { - if (onTap != null) { - polyline.onClick.listen((event) { - onTap.call(); - }); - } - } - - /// Returns the wrapped [gmaps.Polyline]. Only used for testing. - @visibleForTesting - gmaps.Polyline? get line => _polyline; - - /// Returns `true` if this Controller will use its own `onTap` handler to consume events. - bool get consumeTapEvents => _consumeTapEvents; - - /// Updates the options of the wrapped [gmaps.Polyline] object. - /// - /// This cannot be called after [remove]. - void update(gmaps.PolylineOptions options) { - assert( - _polyline != null, 'Cannot `update` Polyline after calling `remove`.'); - _polyline!.options = options; - } - - /// Disposes of the currently wrapped [gmaps.Polyline]. - void remove() { - if (_polyline != null) { - _polyline!.visible = false; - _polyline!.map = null; - _polyline = null; - } - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polylines.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polylines.dart deleted file mode 100644 index 695b29554c04..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/polylines.dart +++ /dev/null @@ -1,83 +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. - -part of google_maps_flutter_web; - -/// This class manages a set of [PolylinesController]s associated to a [GoogleMapController]. -class PolylinesController extends GeometryController { - // A cache of [PolylineController]s indexed by their [PolylineId]. - final Map _polylineIdToController; - - // The stream over which polylines broadcast their events - StreamController _streamController; - - /// Initializes the cache. The [StreamController] comes from the [GoogleMapController], and is shared with other controllers. - PolylinesController({ - required StreamController stream, - }) : _streamController = stream, - _polylineIdToController = Map(); - - /// Returns the cache of [PolylineContrller]s. Test only. - @visibleForTesting - Map get lines => _polylineIdToController; - - /// Adds a set of [Polyline] objects to the cache. - /// - /// Wraps each line into its corresponding [PolylineController]. - void addPolylines(Set polylinesToAdd) { - polylinesToAdd.forEach((polyline) { - _addPolyline(polyline); - }); - } - - void _addPolyline(Polyline polyline) { - if (polyline == null) { - return; - } - - final polylineOptions = _polylineOptionsFromPolyline(googleMap, polyline); - gmaps.Polyline gmPolyline = gmaps.Polyline(polylineOptions); - gmPolyline.map = googleMap; - PolylineController controller = PolylineController( - polyline: gmPolyline, - consumeTapEvents: polyline.consumeTapEvents, - onTap: () { - _onPolylineTap(polyline.polylineId); - }); - _polylineIdToController[polyline.polylineId] = controller; - } - - /// Updates a set of [Polyline] objects with new options. - void changePolylines(Set polylinesToChange) { - polylinesToChange.forEach((polylineToChange) { - _changePolyline(polylineToChange); - }); - } - - void _changePolyline(Polyline polyline) { - PolylineController? polylineController = - _polylineIdToController[polyline.polylineId]; - polylineController - ?.update(_polylineOptionsFromPolyline(googleMap, polyline)); - } - - /// Removes a set of [PolylineId]s from the cache. - void removePolylines(Set polylineIdsToRemove) { - polylineIdsToRemove.forEach((polylineId) { - final PolylineController? polylineController = - _polylineIdToController[polylineId]; - polylineController?.remove(); - _polylineIdToController.remove(polylineId); - }); - } - - // Handle internal events - - bool _onPolylineTap(PolylineId polylineId) { - // Have you ended here on your debugging? Is this wrong? - // Comment here: https://github.com/flutter/flutter/issues/64084 - _streamController.add(PolylineTapEvent(mapId, polylineId)); - return _polylineIdToController[polylineId]?.consumeTapEvents ?? false; - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/LICENSE b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/LICENSE new file mode 100644 index 000000000000..ab4e163abe54 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2008 Krasimir Tsonev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/README.md b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/README.md new file mode 100644 index 000000000000..8bd4a39c065f --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/README.md @@ -0,0 +1,14 @@ +# to_screen_location + +The code in this directory is a Dart re-implementation of Krasimir Tsonev's blog +post: [GoogleMaps API v3: convert LatLng object to actual pixels][blog-post]. + +The blog post describes a way to implement the [`toScreenLocation` method][method] +of the Google Maps Platform SDK for the web. + +Used under license (MIT), [available here][blog-license], and in the accompanying +LICENSE file. + +[blog-license]: https://krasimirtsonev.com/license +[blog-post]: https://krasimirtsonev.com/blog/article/google-maps-api-v3-convert-latlng-object-to-actual-pixels-point-object +[method]: https://developers.google.com/maps/documentation/android-sdk/reference/com/google/android/libraries/maps/Projection#toScreenLocation(com.google.android.libraries.maps.model.LatLng) diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/to_screen_location.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/to_screen_location.dart new file mode 100644 index 000000000000..2963111fdcc3 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/third_party/to_screen_location/to_screen_location.dart @@ -0,0 +1,57 @@ +// The MIT License (MIT) +// +// Copyright (c) 2008 Krasimir Tsonev +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'package:google_maps/google_maps.dart' as gmaps; + +/// Returns a screen location that corresponds to a geographical coordinate ([gmaps.LatLng]). +/// +/// The screen location is in pixels relative to the top left of the Map widget +/// (not of the whole screen/app). +/// +/// See: https://developers.google.com/maps/documentation/android-sdk/reference/com/google/android/libraries/maps/Projection#public-point-toscreenlocation-latlng-location +gmaps.Point toScreenLocation(gmaps.GMap map, gmaps.LatLng coords) { + final zoom = map.zoom; + final bounds = map.bounds; + final projection = map.projection; + + assert( + bounds != null, 'Map Bounds required to compute screen x/y of LatLng.'); + assert(projection != null, + 'Map Projection required to compute screen x/y of LatLng.'); + assert(zoom != null, + 'Current map zoom level required to compute screen x/y of LatLng.'); + + final ne = bounds!.northEast; + final sw = bounds.southWest; + + final topRight = projection!.fromLatLngToPoint!(ne)!; + final bottomLeft = projection.fromLatLngToPoint!(sw)!; + + final scale = 1 << (zoom!.toInt()); // 2 ^ zoom + + final worldPoint = projection.fromLatLngToPoint!(coords)!; + + return gmaps.Point( + ((worldPoint.x! - bottomLeft.x!) * scale).toInt(), + ((worldPoint.y! - topRight.y!) * scale).toInt(), + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/types.dart b/packages/google_maps_flutter/google_maps_flutter_web/lib/src/types.dart deleted file mode 100644 index ff980eb4c34b..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/lib/src/types.dart +++ /dev/null @@ -1,30 +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:google_maps_flutter_web/google_maps_flutter_web.dart'; -import 'package:google_maps/google_maps.dart' as gmaps; - -/// A void function that handles a [gmaps.LatLng] as a parameter. -/// -/// Similar to [ui.VoidCallback], but specific for Marker drag events. -typedef LatLngCallback = void Function(gmaps.LatLng latLng); - -/// The base class for all "geometry" group controllers. -/// -/// This lets all Geometry controllers ([MarkersController], [CirclesController], -/// [PolygonsController], [PolylinesController]) to be bound to a [gmaps.GMap] -/// instance and our internal `mapId` value. -abstract class GeometryController { - /// The GMap instance that this controller operates on. - late gmaps.GMap googleMap; - - /// The map ID for events. - late int mapId; - - /// Binds a `mapId` and the [gmaps.GMap] instance to this controller. - void bindToMap(int mapId, gmaps.GMap googleMap) { - this.mapId = mapId; - this.googleMap = googleMap; - } -} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml deleted file mode 100644 index c69b8e55fa1c..000000000000 --- a/packages/google_maps_flutter/google_maps_flutter_web/pubspec.yaml +++ /dev/null @@ -1,32 +0,0 @@ -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 - -environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" - -flutter: - plugin: - platforms: - web: - pluginClass: GoogleMapsPlugin - fileName: google_maps_flutter_web.dart - -dependencies: - flutter: - sdk: flutter - flutter_web_plugins: - sdk: flutter - google_maps_flutter_platform_interface: ^2.0.1 - google_maps: ^5.1.0 - meta: ^1.3.0 - sanitize_html: ^2.0.0 - stream_transform: ^2.0.0 - -dev_dependencies: - flutter_test: - sdk: flutter - pedantic: ^1.10.0 diff --git a/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java b/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java new file mode 100644 index 000000000000..3b6ad960f548 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/android/src/test/java/io/flutter/plugins/googlesignin/GoogleSignInTest.java @@ -0,0 +1,198 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlesignin; + +import static org.mockito.Mockito.mock; +import 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(); + plugin.initInstance( + mock(BinaryMessenger.class), mock(Context.class), mock(GoogleSignInWrapper.class)); + + plugin.onMethodCall(new MethodCall("signIn", null), null); + } +} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java new file mode 100644 index 000000000000..edc01de491af --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/FlutterActivityTest.java @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlesigninexample; + +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 + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/GoogleSignInTest.java b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/GoogleSignInTest.java new file mode 100644 index 000000000000..561d9d4e7a82 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/androidTest/java/io/flutter/plugins/googlesigninexample/GoogleSignInTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlesigninexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.googlesignin.GoogleSignInPlugin; +import org.junit.Test; + +public class GoogleSignInTest { + @Test + public void googleSignInPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(GoogleSignInTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(GoogleSignInPlugin.class)); + }); + } +} diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/debug/AndroidManifest.xml b/packages/google_sign_in/google_sign_in/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..4d764900a530 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/GoogleSignInTestActivity.java b/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/GoogleSignInTestActivity.java new file mode 100644 index 000000000000..09506a2632df --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/android/app/src/main/java/io/flutter/plugins/googlesigninexample/GoogleSignInTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.googlesigninexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Makes the FlutterEngine accessible for testing. +public class GoogleSignInTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/google_sign_in/google_sign_in/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/resources/README.md b/packages/google_sign_in/google_sign_in/resources/README.md new file mode 100644 index 000000000000..b3f0383a6695 --- /dev/null +++ b/packages/google_sign_in/google_sign_in/resources/README.md @@ -0,0 +1,7 @@ +`transparentImage.gif` is a 1x1 transparent gif which comes from [this wikimedia page](https://commons.wikimedia.org/wiki/File:Transparent.gif): + +![](transparentImage.gif) + +This is the image used a placeholder for the `GoogleCircleAvatar` widget. + +The variable `_transparentImage` in `lib/widgets.dart` is the list of bytes of `transparentImage.gif`. \ No newline at end of file diff --git a/packages/google_sign_in/google_sign_in/resources/transparentImage.gif b/packages/google_sign_in/google_sign_in/resources/transparentImage.gif new file mode 100644 index 000000000000..f191b280ce91 Binary files /dev/null and b/packages/google_sign_in/google_sign_in/resources/transparentImage.gif differ diff --git a/packages/google_sign_in/google_sign_in/test/widgets_test.dart b/packages/google_sign_in/google_sign_in/test/widgets_test.dart new file mode 100644 index 000000000000..f7bd6f803e7c --- /dev/null +++ b/packages/google_sign_in/google_sign_in/test/widgets_test.dart @@ -0,0 +1,117 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_sign_in/google_sign_in.dart'; + +/// A instantiable class that extends [GoogleIdentity] +class _TestGoogleIdentity extends GoogleIdentity { + _TestGoogleIdentity({ + required this.id, + required this.email, + this.photoUrl, + }); + + final String id; + final String email; + + final String? photoUrl; + + @override + String? get displayName => null; + + @override + String? get serverAuthCode => null; +} + +/// A mocked [HttpClient] which always returns a [_MockHttpRequest]. +class _MockHttpClient extends Fake implements HttpClient { + @override + bool autoUncompress = true; + + @override + Future getUrl(Uri url) { + return Future.value(_MockHttpRequest()); + } +} + +/// A mocked [HttpClientRequest] which always returns a [_MockHttpClientResponse]. +class _MockHttpRequest extends Fake implements HttpClientRequest { + @override + Future close() { + return Future.value(_MockHttpResponse()); + } +} + +/// Arbitrary valid image returned by the [_MockHttpResponse]. +/// +/// This is an transparent 1x1 gif image. +/// It doesn't have to match the placeholder used in [GoogleUserCircleAvatar]. +/// +/// Those bytes come from `resources/transparentImage.gif`. +final Uint8List _transparentImage = Uint8List.fromList( + [ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x21, 0xf9, 0x04, 0x01, 0x00, // + 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, // + 0x00, 0x02, 0x01, 0x44, 0x00, 0x3B + ], +); + +/// A mocked [HttpClientResponse] which is empty and has a [statusCode] of 200 +/// and returns valid image. +class _MockHttpResponse extends Fake implements HttpClientResponse { + final Stream _delegate = + Stream.value(_transparentImage); + + @override + int get contentLength => -1; + + @override + HttpClientResponseCompressionState get compressionState { + return HttpClientResponseCompressionState.decompressed; + } + + @override + StreamSubscription listen(void Function(Uint8List event)? onData, + {Function? onError, void Function()? onDone, bool? cancelOnError}) { + return _delegate.listen(onData, + onError: onError, onDone: onDone, cancelOnError: cancelOnError); + } + + @override + int get statusCode => 200; +} + +void main() { + testWidgets('It should build the GoogleUserCircleAvatar successfully', + (WidgetTester tester) async { + final GoogleIdentity identity = _TestGoogleIdentity( + email: 'email@email.com', + id: 'userId', + photoUrl: 'photoUrl', + ); + tester.binding.window.physicalSizeTestValue = Size(100, 100); + + await HttpOverrides.runZoned( + () async { + await tester.pumpWidget(MaterialApp( + home: SizedBox( + height: 100, + width: 100, + child: GoogleUserCircleAvatar( + identity: identity, + ), + ), + )); + }, + createHttpClient: (SecurityContext? c) => _MockHttpClient(), + ); + }); +} diff --git a/packages/image_picker/image_picker/android/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 new file mode 100644 index 000000000000..32e3ebc6183d --- /dev/null +++ b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java @@ -0,0 +1,66 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepicker; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowContentResolver; + +@RunWith(RobolectricTestRunner.class) +public class FileUtilTest { + + private Context context; + private FileUtils fileUtils; + ShadowContentResolver shadowContentResolver; + + @Before + public void before() { + context = ApplicationProvider.getApplicationContext(); + shadowContentResolver = shadowOf(context.getContentResolver()); + fileUtils = new FileUtils(); + } + + @Test + public void FileUtil_GetPathFromUri() throws IOException { + Uri uri = Uri.parse("content://dummy/dummy.png"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("imageStream".getBytes(UTF_8))); + String path = fileUtils.getPathFromUri(context, uri); + File file = new File(path); + int size = (int) file.length(); + byte[] bytes = new byte[size]; + + BufferedInputStream buf = new BufferedInputStream(new FileInputStream(file)); + buf.read(bytes, 0, bytes.length); + buf.close(); + + assertTrue(bytes.length > 0); + String imageStream = new String(bytes, UTF_8); + assertTrue(imageStream.equals("imageStream")); + } + + @Test + public void FileUtil_getImageExtension() throws IOException { + Uri uri = Uri.parse("content://dummy/dummy.png"); + shadowContentResolver.registerInputStream( + uri, new ByteArrayInputStream("imageStream".getBytes(UTF_8))); + String path = fileUtils.getPathFromUri(context, uri); + assertTrue(path.endsWith(".jpg")); + } +} diff --git a/packages/image_picker/image_picker/android/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 new file mode 100644 index 000000000000..92070e7a65c5 --- /dev/null +++ b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerCacheTest.java @@ -0,0 +1,115 @@ +// Copyright 2013 The Flutter 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.imagepicker; + +import static io.flutter.plugins.imagepicker.ImagePickerCache.MAP_KEY_IMAGE_QUALITY; +import static io.flutter.plugins.imagepicker.ImagePickerCache.SHARED_PREFERENCES_NAME; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import io.flutter.plugin.common.MethodCall; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class ImagePickerCacheTest { + private static final int IMAGE_QUALITY = 90; + + @Mock Activity mockActivity; + @Mock SharedPreferences mockPreference; + @Mock SharedPreferences.Editor mockEditor; + @Mock MethodCall mockMethodCall; + + static Map preferenceStorage; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + preferenceStorage = new HashMap(); + when(mockActivity.getPackageName()).thenReturn("com.example.test"); + when(mockActivity.getPackageManager()).thenReturn(mock(PackageManager.class)); + when(mockActivity.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)) + .thenReturn(mockPreference); + when(mockPreference.edit()).thenReturn(mockEditor); + when(mockEditor.putInt(any(String.class), any(int.class))) + .then( + i -> { + preferenceStorage.put(i.getArgument(0), i.getArgument(1)); + return mockEditor; + }); + when(mockEditor.putLong(any(String.class), any(long.class))) + .then( + i -> { + preferenceStorage.put(i.getArgument(0), i.getArgument(1)); + return mockEditor; + }); + when(mockEditor.putString(any(String.class), any(String.class))) + .then( + i -> { + preferenceStorage.put(i.getArgument(0), i.getArgument(1)); + return mockEditor; + }); + + when(mockPreference.getInt(any(String.class), any(int.class))) + .then( + i -> { + int result = + (int) + ((preferenceStorage.get(i.getArgument(0)) != null) + ? preferenceStorage.get(i.getArgument(0)) + : i.getArgument(1)); + return result; + }); + when(mockPreference.getLong(any(String.class), any(long.class))) + .then( + i -> { + long result = + (long) + ((preferenceStorage.get(i.getArgument(0)) != null) + ? preferenceStorage.get(i.getArgument(0)) + : i.getArgument(1)); + return result; + }); + when(mockPreference.getString(any(String.class), any(String.class))) + .then( + i -> { + String result = + (String) + ((preferenceStorage.get(i.getArgument(0)) != null) + ? preferenceStorage.get(i.getArgument(0)) + : i.getArgument(1)); + return result; + }); + + when(mockPreference.contains(any(String.class))).thenReturn(true); + } + + @Test + public void ImageCache_ShouldBeAbleToSetAndGetQuality() { + when(mockMethodCall.argument(MAP_KEY_IMAGE_QUALITY)).thenReturn(IMAGE_QUALITY); + ImagePickerCache cache = new ImagePickerCache(mockActivity); + cache.saveDimensionWithMethodCall(mockMethodCall); + Map resultMap = cache.getCacheMap(); + int imageQuality = (int) resultMap.get(cache.MAP_KEY_IMAGE_QUALITY); + assertThat(imageQuality, equalTo(IMAGE_QUALITY)); + + when(mockMethodCall.argument(MAP_KEY_IMAGE_QUALITY)).thenReturn(null); + cache.saveDimensionWithMethodCall(mockMethodCall); + Map resultMapWithDefaultQuality = cache.getCacheMap(); + int defaultImageQuality = (int) resultMapWithDefaultQuality.get(cache.MAP_KEY_IMAGE_QUALITY); + assertThat(defaultImageQuality, equalTo(100)); + } +} diff --git a/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java new file mode 100644 index 000000000000..d2ee7b0b7d61 --- /dev/null +++ b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerDelegateTest.java @@ -0,0 +1,434 @@ +// Copyright 2013 The Flutter 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.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; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import android.Manifest; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +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; +import org.mockito.MockitoAnnotations; + +public class ImagePickerDelegateTest { + private static final Double WIDTH = 10.0; + private static final Double HEIGHT = 10.0; + private static final Double MAX_DURATION = 10.0; + private static final Integer IMAGE_QUALITY = 90; + + @Mock Activity mockActivity; + @Mock ImageResizer mockImageResizer; + @Mock MethodCall mockMethodCall; + @Mock MethodChannel.Result mockResult; + @Mock ImagePickerDelegate.PermissionManager mockPermissionManager; + @Mock FileUtils mockFileUtils; + @Mock Intent mockIntent; + @Mock ImagePickerCache cache; + + ImagePickerDelegate.FileUriResolver mockFileUriResolver; + MockedStatic mockStaticFile; + + private static class MockFileUriResolver implements ImagePickerDelegate.FileUriResolver { + @Override + public Uri resolveFileProviderUriForFile(String fileProviderName, File imageFile) { + return null; + } + + @Override + public void getFullImagePath(Uri imageUri, ImagePickerDelegate.OnPathReadyListener listener) { + listener.onPathReady("pathFromUri"); + } + } + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mockStaticFile = Mockito.mockStatic(File.class); + mockStaticFile + .when(() -> File.createTempFile(any(), any(), any())) + .thenReturn(new File("/tmpfile")); + + when(mockActivity.getPackageName()).thenReturn("com.example.test"); + when(mockActivity.getPackageManager()).thenReturn(mock(PackageManager.class)); + + when(mockFileUtils.getPathFromUri(any(Context.class), any(Uri.class))) + .thenReturn("pathFromUri"); + + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", null, null, null)) + .thenReturn("originalPath"); + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", null, null, IMAGE_QUALITY)) + .thenReturn("originalPath"); + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", WIDTH, HEIGHT, null)) + .thenReturn("scaledPath"); + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", WIDTH, null, null)) + .thenReturn("scaledPath"); + when(mockImageResizer.resizeImageIfNeeded("pathFromUri", null, HEIGHT, null)) + .thenReturn("scaledPath"); + + mockFileUriResolver = new MockFileUriResolver(); + + Uri mockUri = mock(Uri.class); + when(mockIntent.getData()).thenReturn(mockUri); + } + + @After + public void tearDown() { + mockStaticFile.close(); + } + + @Test + public void whenConstructed_setsCorrectFileProviderName() { + ImagePickerDelegate delegate = createDelegate(); + assertThat(delegate.fileProviderName, equalTo("com.example.test.flutter.image_provider")); + } + + @Test + public void chooseImageFromGallery_WhenPendingResultExists_FinishesWithAlreadyActiveError() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.chooseImageFromGallery(mockMethodCall, mockResult); + + verifyFinishedWithAlreadyActiveError(); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void chooseMultiImageFromGallery_WhenPendingResultExists_FinishesWithAlreadyActiveError() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.chooseMultiImageFromGallery(mockMethodCall, mockResult); + + verifyFinishedWithAlreadyActiveError(); + verifyNoMoreInteractions(mockResult); + } + + public void + chooseImageFromGallery_WhenHasExternalStoragePermission_LaunchesChooseFromGalleryIntent() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.READ_EXTERNAL_STORAGE)) + .thenReturn(true); + + ImagePickerDelegate delegate = createDelegate(); + delegate.chooseImageFromGallery(mockMethodCall, mockResult); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY)); + } + + @Test + public void takeImageWithCamera_WhenPendingResultExists_FinishesWithAlreadyActiveError() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verifyFinishedWithAlreadyActiveError(); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void takeImageWithCamera_WhenHasNoCameraPermission_RequestsForPermission() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(false); + when(mockPermissionManager.needRequestCameraPermission()).thenReturn(true); + + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verify(mockPermissionManager) + .askForPermission( + Manifest.permission.CAMERA, ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION); + } + + @Test + public void takeImageWithCamera_WhenCameraPermissionNotPresent_RequestsForPermission() { + when(mockPermissionManager.needRequestCameraPermission()).thenReturn(false); + + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); + } + + @Test + public void + takeImageWithCamera_WhenHasCameraPermission_AndAnActivityCanHandleCameraIntent_LaunchesTakeWithCameraIntent() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); + + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); + } + + @Test + public void + takeImageWithCamera_WhenHasCameraPermission_AndNoActivityToHandleCameraIntent_FinishesWithNoCamerasAvailableError() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); + doThrow(ActivityNotFoundException.class) + .when(mockActivity) + .startActivityForResult(any(Intent.class), anyInt()); + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + verify(mockResult) + .error("no_available_camera", "No cameras available for taking pictures.", null); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void takeImageWithCamera_WritesImageToCacheDirectory() { + when(mockPermissionManager.isPermissionGranted(Manifest.permission.CAMERA)).thenReturn(true); + + ImagePickerDelegate delegate = createDelegate(); + delegate.takeImageWithCamera(mockMethodCall, mockResult); + + mockStaticFile.verify( + () -> File.createTempFile(any(), eq(".jpg"), eq(new File("/image_picker_cache"))), + times(1)); + } + + @Test + public void onRequestPermissionsResult_WhenCameraPermissionDenied_FinishesWithError() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.onRequestPermissionsResult( + ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION, + new String[] {Manifest.permission.CAMERA}, + new int[] {PackageManager.PERMISSION_DENIED}); + + verify(mockResult).error("camera_access_denied", "The user did not allow camera access.", null); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onRequestTakeVideoPermissionsResult_WhenCameraPermissionGranted_LaunchesTakeVideoWithCameraIntent() { + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onRequestPermissionsResult( + ImagePickerDelegate.REQUEST_CAMERA_VIDEO_PERMISSION, + new String[] {Manifest.permission.CAMERA}, + new int[] {PackageManager.PERMISSION_GRANTED}); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA)); + } + + @Test + public void + onRequestTakeImagePermissionsResult_WhenCameraPermissionGranted_LaunchesTakeWithCameraIntent() { + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onRequestPermissionsResult( + ImagePickerDelegate.REQUEST_CAMERA_IMAGE_PERMISSION, + new String[] {Manifest.permission.CAMERA}, + new int[] {PackageManager.PERMISSION_GRANTED}); + + verify(mockActivity) + .startActivityForResult( + any(Intent.class), eq(ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA)); + } + + @Test + public void onActivityResult_WhenPickFromGalleryCanceled_FinishesWithNull() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY, Activity.RESULT_CANCELED, null); + + verify(mockResult).success(null); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onActivityResult_WhenImagePickedFromGallery_AndNoResizeNeeded_FinishesWithImagePath() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY, Activity.RESULT_OK, mockIntent); + + verify(mockResult).success("originalPath"); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onActivityResult_WhenImagePickedFromGallery_AndResizeNeeded_FinishesWithScaledImagePath() { + when(mockMethodCall.argument("maxWidth")).thenReturn(WIDTH); + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_CHOOSE_IMAGE_FROM_GALLERY, Activity.RESULT_OK, mockIntent); + + verify(mockResult).success("scaledPath"); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onActivityResult_WhenVideoPickedFromGallery_AndResizeParametersSupplied_FinishesWithFilePath() { + when(mockMethodCall.argument("maxWidth")).thenReturn(WIDTH); + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_CHOOSE_VIDEO_FROM_GALLERY, Activity.RESULT_OK, mockIntent); + + verify(mockResult).success("pathFromUri"); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void onActivityResult_WhenTakeImageWithCameraCanceled_FinishesWithNull() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA, Activity.RESULT_CANCELED, null); + + verify(mockResult).success(null); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void onActivityResult_WhenImageTakenWithCamera_AndNoResizeNeeded_FinishesWithImagePath() { + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA, Activity.RESULT_OK, mockIntent); + + verify(mockResult).success("originalPath"); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onActivityResult_WhenImageTakenWithCamera_AndResizeNeeded_FinishesWithScaledImagePath() { + when(mockMethodCall.argument("maxWidth")).thenReturn(WIDTH); + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_TAKE_IMAGE_WITH_CAMERA, Activity.RESULT_OK, mockIntent); + + verify(mockResult).success("scaledPath"); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onActivityResult_WhenVideoTakenWithCamera_AndResizeParametersSupplied_FinishesWithFilePath() { + when(mockMethodCall.argument("maxWidth")).thenReturn(WIDTH); + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA, Activity.RESULT_OK, mockIntent); + + verify(mockResult).success("pathFromUri"); + verifyNoMoreInteractions(mockResult); + } + + @Test + public void + onActivityResult_WhenVideoTakenWithCamera_AndMaxDurationParametersSupplied_FinishesWithFilePath() { + when(mockMethodCall.argument("maxDuration")).thenReturn(MAX_DURATION); + + ImagePickerDelegate delegate = createDelegateWithPendingResultAndMethodCall(); + delegate.onActivityResult( + ImagePickerDelegate.REQUEST_CODE_TAKE_VIDEO_WITH_CAMERA, Activity.RESULT_OK, mockIntent); + + verify(mockResult).success("pathFromUri"); + 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, + new File("/image_picker_cache"), + mockImageResizer, + null, + null, + cache, + mockPermissionManager, + mockFileUriResolver, + mockFileUtils); + } + + private ImagePickerDelegate createDelegateWithPendingResultAndMethodCall() { + return new ImagePickerDelegate( + mockActivity, + new File("/image_picker_cache"), + mockImageResizer, + mockResult, + mockMethodCall, + cache, + mockPermissionManager, + mockFileUriResolver, + mockFileUtils); + } + + private void verifyFinishedWithAlreadyActiveError() { + verify(mockResult).error("already_active", "Image picker is already active", null); + } +} diff --git a/packages/image_picker/image_picker/android/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 new file mode 100644 index 000000000000..422b8be74f7c --- /dev/null +++ b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImagePickerPluginTest.java @@ -0,0 +1,189 @@ +// Copyright 2013 The Flutter 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.imagepicker; + +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.app.Application; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class ImagePickerPluginTest { + private static final int SOURCE_CAMERA = 0; + private static final int SOURCE_GALLERY = 1; + private static final String PICK_IMAGE = "pickImage"; + private static final String PICK_MULTI_IMAGE = "pickMultiImage"; + private static final String PICK_VIDEO = "pickVideo"; + + @Rule public ExpectedException exception = ExpectedException.none(); + + @SuppressWarnings("deprecation") + @Mock + io.flutter.plugin.common.PluginRegistry.Registrar mockRegistrar; + + @Mock Activity mockActivity; + @Mock Application mockApplication; + @Mock ImagePickerDelegate mockImagePickerDelegate; + @Mock MethodChannel.Result mockResult; + + ImagePickerPlugin plugin; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(mockRegistrar.context()).thenReturn(mockApplication); + + plugin = new ImagePickerPlugin(mockImagePickerDelegate, mockActivity); + } + + @Test + public void onMethodCall_WhenActivityIsNull_FinishesWithForegroundActivityRequiredError() { + MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_GALLERY); + ImagePickerPlugin imagePickerPluginWithNullActivity = + new ImagePickerPlugin(mockImagePickerDelegate, null); + imagePickerPluginWithNullActivity.onMethodCall(call, mockResult); + verify(mockResult) + .error("no_activity", "image_picker plugin requires a foreground activity.", null); + verifyZeroInteractions(mockImagePickerDelegate); + } + + @Test + public void onMethodCall_WhenCalledWithUnknownMethod_ThrowsException() { + exception.expect(IllegalArgumentException.class); + exception.expectMessage("Unknown method test"); + plugin.onMethodCall(new MethodCall("test", null), mockResult); + verifyZeroInteractions(mockImagePickerDelegate); + verifyZeroInteractions(mockResult); + } + + @Test + public void onMethodCall_WhenCalledWithUnknownImageSource_ThrowsException() { + exception.expect(IllegalArgumentException.class); + exception.expectMessage("Invalid image source: -1"); + plugin.onMethodCall(buildMethodCall(PICK_IMAGE, -1), mockResult); + verifyZeroInteractions(mockImagePickerDelegate); + verifyZeroInteractions(mockResult); + } + + @Test + public void onMethodCall_WhenSourceIsGallery_InvokesChooseImageFromGallery() { + MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_GALLERY); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).chooseImageFromGallery(eq(call), any()); + verifyZeroInteractions(mockResult); + } + + @Test + public void onMethodCall_InvokesChooseMultiImageFromGallery() { + MethodCall call = buildMethodCall(PICK_MULTI_IMAGE); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).chooseMultiImageFromGallery(eq(call), any()); + verifyZeroInteractions(mockResult); + } + + @Test + public void onMethodCall_WhenSourceIsCamera_InvokesTakeImageWithCamera() { + MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_CAMERA); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).takeImageWithCamera(eq(call), any()); + verifyZeroInteractions(mockResult); + } + + @Test + public void onMethodCall_PickingImage_WhenSourceIsCamera_InvokesTakeImageWithCamera_RearCamera() { + MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_CAMERA); + HashMap arguments = (HashMap) call.arguments; + arguments.put("cameraDevice", 0); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).setCameraDevice(eq(CameraDevice.REAR)); + } + + @Test + public void + onMethodCall_PickingImage_WhenSourceIsCamera_InvokesTakeImageWithCamera_FrontCamera() { + MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_CAMERA); + HashMap arguments = (HashMap) call.arguments; + arguments.put("cameraDevice", 1); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).setCameraDevice(eq(CameraDevice.FRONT)); + } + + @Test + public void onMethodCall_PickingVideo_WhenSourceIsCamera_InvokesTakeImageWithCamera_RearCamera() { + MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_CAMERA); + HashMap arguments = (HashMap) call.arguments; + arguments.put("cameraDevice", 0); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).setCameraDevice(eq(CameraDevice.REAR)); + } + + @Test + public void + onMethodCall_PickingVideo_WhenSourceIsCamera_InvokesTakeImageWithCamera_FrontCamera() { + MethodCall call = buildMethodCall(PICK_IMAGE, SOURCE_CAMERA); + HashMap arguments = (HashMap) call.arguments; + arguments.put("cameraDevice", 1); + plugin.onMethodCall(call, mockResult); + verify(mockImagePickerDelegate).setCameraDevice(eq(CameraDevice.FRONT)); + } + + @Test + public void onResiter_WhenAcitivityIsNull_ShouldNotCrash() { + when(mockRegistrar.activity()).thenReturn(null); + ImagePickerPlugin.registerWith((mockRegistrar)); + assertTrue( + "No exception thrown when ImagePickerPlugin.registerWith ran with activity = null", true); + } + + @Test + public void onConstructor_WhenContextTypeIsActivity_ShouldNotCrash() { + new ImagePickerPlugin(mockImagePickerDelegate, mockActivity); + assertTrue( + "No exception thrown when ImagePickerPlugin() ran with context instanceof Activity", true); + } + + @Test + public void constructDelegate_ShouldUseInternalCacheDirectory() { + File mockDirectory = new File("/mockpath"); + when(mockActivity.getCacheDir()).thenReturn(mockDirectory); + + ImagePickerDelegate delegate = plugin.constructDelegate(mockActivity); + + verify(mockActivity, times(1)).getCacheDir(); + assertThat( + "Delegate uses cache directory for storing camera captures", + delegate.externalFilesDirectory, + equalTo(mockDirectory)); + } + + private MethodCall buildMethodCall(String method, final int source) { + final Map arguments = new HashMap<>(); + arguments.put("source", source); + + return new MethodCall(method, arguments); + } + + private MethodCall buildMethodCall(String method) { + return new MethodCall(method, null); + } +} diff --git a/packages/image_picker/image_picker/android/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 new file mode 100644 index 000000000000..73cfef9e88ea --- /dev/null +++ b/packages/image_picker/image_picker/android/src/test/java/io/flutter/plugins/imagepicker/ImageResizerTest.java @@ -0,0 +1,73 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepicker; + +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertThat; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import java.io.File; +import java.io.IOException; +import org.junit.Before; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +// RobolectricTestRunner always creates a default mock bitmap when reading from file. So we cannot actually test the scaling. +// But we can still test whether the original or scaled file is created. +@RunWith(RobolectricTestRunner.class) +public class ImageResizerTest { + + ImageResizer resizer; + File imageFile; + File externalDirectory; + Bitmap originalImageBitmap; + + @Before + public void setUp() throws IOException { + MockitoAnnotations.initMocks(this); + imageFile = new File(getClass().getClassLoader().getResource("pngImage.png").getFile()); + originalImageBitmap = BitmapFactory.decodeFile(imageFile.getPath()); + TemporaryFolder temporaryFolder = new TemporaryFolder(); + temporaryFolder.create(); + externalDirectory = temporaryFolder.newFolder("image_picker_testing_path"); + resizer = new ImageResizer(externalDirectory, new ExifDataCopier()); + } + + @Test + public void onResizeImageIfNeeded_WhenQualityIsNull_ShoultNotResize_ReturnTheUnscaledFile() { + String outoutFile = resizer.resizeImageIfNeeded(imageFile.getPath(), null, null, null); + assertThat(outoutFile, equalTo(imageFile.getPath())); + } + + @Test + public void onResizeImageIfNeeded_WhenQualityIsNotNull_ShoulResize_ReturnResizedFile() { + String outoutFile = resizer.resizeImageIfNeeded(imageFile.getPath(), null, null, 50); + assertThat(outoutFile, equalTo(externalDirectory.getPath() + "/scaled_pngImage.png")); + } + + @Test + public void onResizeImageIfNeeded_WhenWidthIsNotNull_ShoulResize_ReturnResizedFile() { + String outoutFile = resizer.resizeImageIfNeeded(imageFile.getPath(), 50.0, null, null); + assertThat(outoutFile, equalTo(externalDirectory.getPath() + "/scaled_pngImage.png")); + } + + @Test + public void onResizeImageIfNeeded_WhenHeightIsNotNull_ShoulResize_ReturnResizedFile() { + String outoutFile = resizer.resizeImageIfNeeded(imageFile.getPath(), null, 50.0, null); + assertThat(outoutFile, equalTo(externalDirectory.getPath() + "/scaled_pngImage.png")); + } + + @Test + public void onResizeImageIfNeeded_WhenParentDirectoryDoesNotExists_ShouldNotCrash() { + File nonExistentDirectory = new File(externalDirectory, "/nonExistent"); + ImageResizer invalidResizer = new ImageResizer(nonExistentDirectory, new ExifDataCopier()); + String outoutFile = invalidResizer.resizeImageIfNeeded(imageFile.getPath(), null, 50.0, null); + assertThat(outoutFile, equalTo(nonExistentDirectory.getPath() + "/scaled_pngImage.png")); + } +} diff --git a/packages/image_picker/image_picker/android/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 new file mode 100644 index 000000000000..ca6ee9cea8ec --- /dev/null +++ b/packages/image_picker/image_picker/android/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/packages/image_picker/image_picker/android/src/test/resources/pngImage.png b/packages/image_picker/image_picker/android/src/test/resources/pngImage.png new file mode 100644 index 000000000000..22ac5a5a1485 Binary files /dev/null and b/packages/image_picker/image_picker/android/src/test/resources/pngImage.png differ diff --git a/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java new file mode 100644 index 000000000000..91e068fa8043 --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/FlutterActivityTest.java @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepickerexample; + +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 + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerTest.java b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerTest.java new file mode 100644 index 000000000000..c4a1532d940c --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/androidTest/java/io/flutter/plugins/imagepickerexample/ImagePickerTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepickerexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.imagepicker.ImagePickerPlugin; +import org.junit.Test; + +public class ImagePickerTest { + @Test + public void imagePickerPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(ImagePickerTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(ImagePickerPlugin.class)); + }); + } +} diff --git a/packages/image_picker/image_picker/example/android/app/src/debug/AndroidManifest.xml b/packages/image_picker/image_picker/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..6f85cefded34 --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/ImagePickerTestActivity.java b/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/ImagePickerTestActivity.java new file mode 100644 index 000000000000..827687a10e79 --- /dev/null +++ b/packages/image_picker/image_picker/example/android/app/src/main/java/io/flutter/plugins/imagepickerexample/ImagePickerTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.imagepickerexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Makes the FlutterEngine accessible for testing. +public class ImagePickerTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/image_picker/image_picker/example/integration_test/image_picker_test.dart b/packages/image_picker/image_picker/example/integration_test/image_picker_test.dart new file mode 100644 index 000000000000..2b82b4bda5e4 --- /dev/null +++ b/packages/image_picker/image_picker/example/integration_test/image_picker_test.dart @@ -0,0 +1,12 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +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/RunnerUITests/ImagePickerFromLimitedGalleryUITests.m b/packages/image_picker/image_picker/example/ios/RunnerUITests/ImagePickerFromLimitedGalleryUITests.m new file mode 100644 index 000000000000..802a494b0f5e --- /dev/null +++ b/packages/image_picker/image_picker/example/ios/RunnerUITests/ImagePickerFromLimitedGalleryUITests.m @@ -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 +#import + +const int kLimitedElementWaitingTime = 30; + +@interface ImagePickerFromLimitedGalleryUITests : XCTestCase + +@property(nonatomic, strong) XCUIApplication* app; + +@end + +@implementation ImagePickerFromLimitedGalleryUITests + +- (void)setUp { + [super setUp]; + // Delete the app if already exists, to test permission popups + + self.continueAfterFailure = NO; + self.app = [[XCUIApplication alloc] init]; + [self.app launch]; + __weak typeof(self) weakSelf = self; + [self addUIInterruptionMonitorWithDescription:@"Permission popups" + handler:^BOOL(XCUIElement* _Nonnull interruptingElement) { + 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; + }]; +} + +- (void)tearDown { + [super tearDown]; + [self.app terminate]; +} + +- (void)testSelectingFromGallery { + // Test the `Select Photos` button which is available after iOS 14. + if (@available(iOS 14, *)) { + [self launchPickerAndSelect]; + } else { + return; + } +} + +- (void)launchPickerAndSelect { + // Find and tap on the pick from gallery button. + NSPredicate* predicateToFindImageFromGalleryButton = + [NSPredicate predicateWithFormat:@"label == %@", @"image_picker_example_from_gallery"]; + + XCUIElement* imageFromGalleryButton = + [self.app.otherElements elementMatchingPredicate:predicateToFindImageFromGalleryButton]; + if (![imageFromGalleryButton waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTFail(@"Failed due to not able to find image from gallery button with %@ seconds", + @(kLimitedElementWaitingTime)); + } + + XCTAssertTrue(imageFromGalleryButton.exists); + [imageFromGalleryButton tap]; + + // Find and tap on the `pick` button. + NSPredicate* predicateToFindPickButton = + [NSPredicate predicateWithFormat:@"label == %@", @"PICK"]; + + XCUIElement* pickButton = [self.app.buttons elementMatchingPredicate:predicateToFindPickButton]; + if (![pickButton waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTSkip(@"Pick button isn't found so the test is skipped..."); + } + + XCTAssertTrue(pickButton.exists); + [pickButton tap]; + + // There is a known bug where the permission popups interruption won't get fired until a tap + // happened in the app. We expect a permission popup so we do a tap here. + [self.app tap]; + + // Find an image and tap on it. (IOS 14 UI, images are showing directly) + XCUIElement* aImage; + if (@available(iOS 14, *)) { + aImage = [self.app.scrollViews.firstMatch.images elementBoundByIndex:1]; + } else { + XCUIElement* selectedPhotosCell = [self.app.cells + elementMatchingPredicate:[NSPredicate + predicateWithFormat:@"label == %@", @"Selected Photos"]]; + if (![selectedPhotosCell waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTFail(@"Failed due to not able to find \"Selected Photos\" cell with %@ seconds", + @(kLimitedElementWaitingTime)); + } + [selectedPhotosCell tap]; + aImage = [self.app.collectionViews elementMatchingType:XCUIElementTypeCollectionView + identifier:@"PhotosGridView"] + .cells.firstMatch; + } + os_log_error(OS_LOG_DEFAULT, "description before picking image %@", self.app.debugDescription); + if (![aImage waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTFail(@"Failed due to not able to find an image with %@ seconds", + @(kLimitedElementWaitingTime)); + } + XCTAssertTrue(aImage.exists); + [aImage tap]; + + // Find and tap on the `Done` button. + NSPredicate* predicateToFindDoneButton = + [NSPredicate predicateWithFormat:@"label == %@", @"Done"]; + + XCUIElement* doneButton = [self.app.buttons elementMatchingPredicate:predicateToFindDoneButton]; + if (![doneButton waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTSkip(@"Permissions popup could not fired so the test is skipped..."); + } + + XCTAssertTrue(doneButton.exists); + [doneButton tap]; + + // Find an image and tap on it to have access to selected photos. + if (@available(iOS 14, *)) { + aImage = [self.app.scrollViews.firstMatch.images elementBoundByIndex:1]; + } else { + XCUIElement* selectedPhotosCell = [self.app.cells + elementMatchingPredicate:[NSPredicate + predicateWithFormat:@"label == %@", @"Selected Photos"]]; + if (![selectedPhotosCell waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTFail(@"Failed due to not able to find \"Selected Photos\" cell with %@ seconds", + @(kLimitedElementWaitingTime)); + } + [selectedPhotosCell tap]; + aImage = [self.app.collectionViews elementMatchingType:XCUIElementTypeCollectionView + identifier:@"PhotosGridView"] + .cells.firstMatch; + } + os_log_error(OS_LOG_DEFAULT, "description before picking image %@", self.app.debugDescription); + if (![aImage waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTFail(@"Failed due to not able to find an image with %@ seconds", + @(kLimitedElementWaitingTime)); + } + XCTAssertTrue(aImage.exists); + [aImage tap]; + + // Find the picked image. + NSPredicate* predicateToFindPickedImage = + [NSPredicate predicateWithFormat:@"label == %@", @"image_picker_example_picked_image"]; + + XCUIElement* pickedImage = [self.app.images elementMatchingPredicate:predicateToFindPickedImage]; + if (![pickedImage waitForExistenceWithTimeout:kLimitedElementWaitingTime]) { + os_log_error(OS_LOG_DEFAULT, "%@", self.app.debugDescription); + XCTFail(@"Failed due to not able to find pickedImage with %@ seconds", + @(kLimitedElementWaitingTime)); + } + + XCTAssertTrue(pickedImage.exists); +} + +@end diff --git a/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.h b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.h new file mode 100644 index 000000000000..7ba3d28ef3dd --- /dev/null +++ b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.h @@ -0,0 +1,31 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +#import "FLTImagePickerImageUtil.h" +#import "FLTImagePickerMetaDataUtil.h" +#import "FLTImagePickerPhotoAssetUtil.h" + +/*! + @class FLTPHPickerSaveImageToPathOperation + + @brief The FLTPHPickerSaveImageToPathOperation class + + @discussion This class was implemented to handle saved image paths and populate the pathList + with the final result by using GetSavedPath type block. + + @superclass SuperClass: NSOperation\n + @helps It helps FLTImagePickerPlugin class. + */ +@interface FLTPHPickerSaveImageToPathOperation : NSOperation + +- (instancetype)initWithResult:(PHPickerResult *)result + maxHeight:(NSNumber *)maxHeight + maxWidth:(NSNumber *)maxWidth + desiredImageQuality:(NSNumber *)desiredImageQuality + savedPathBlock:(void (^)(NSString *))savedPathBlock API_AVAILABLE(ios(14)); + +@end diff --git a/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.m b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.m new file mode 100644 index 000000000000..30da22774d07 --- /dev/null +++ b/packages/image_picker/image_picker/ios/Classes/FLTPHPickerSaveImageToPathOperation.m @@ -0,0 +1,132 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTPHPickerSaveImageToPathOperation.h" + +API_AVAILABLE(ios(14)) +@interface FLTPHPickerSaveImageToPathOperation () + +@property(strong, nonatomic) PHPickerResult *result; +@property(assign, nonatomic) NSNumber *maxHeight; +@property(assign, nonatomic) NSNumber *maxWidth; +@property(assign, nonatomic) NSNumber *desiredImageQuality; + +@end + +typedef void (^GetSavedPath)(NSString *); + +@implementation FLTPHPickerSaveImageToPathOperation { + BOOL executing; + BOOL finished; + GetSavedPath getSavedPath; +} + +- (instancetype)initWithResult:(PHPickerResult *)result + maxHeight:(NSNumber *)maxHeight + maxWidth:(NSNumber *)maxWidth + desiredImageQuality:(NSNumber *)desiredImageQuality + savedPathBlock:(GetSavedPath)savedPathBlock API_AVAILABLE(ios(14)) { + if (self = [super init]) { + if (result) { + self.result = result; + self.maxHeight = maxHeight; + self.maxWidth = maxWidth; + self.desiredImageQuality = desiredImageQuality; + getSavedPath = savedPathBlock; + executing = NO; + finished = NO; + } else { + return nil; + } + return self; + } else { + return nil; + } +} + +- (BOOL)isConcurrent { + return YES; +} + +- (BOOL)isExecuting { + return executing; +} + +- (BOOL)isFinished { + return finished; +} + +- (void)setFinished:(BOOL)isFinished { + [self willChangeValueForKey:@"isFinished"]; + self->finished = isFinished; + [self didChangeValueForKey:@"isFinished"]; +} + +- (void)setExecuting:(BOOL)isExecuting { + [self willChangeValueForKey:@"isExecuting"]; + self->executing = isExecuting; + [self didChangeValueForKey:@"isExecuting"]; +} + +- (void)completeOperationWithPath:(NSString *)savedPath { + [self setExecuting:NO]; + [self setFinished:YES]; + getSavedPath(savedPath); +} + +- (void)start { + if ([self isCancelled]) { + [self setFinished:YES]; + return; + } + if (@available(iOS 14, *)) { + [self setExecuting:YES]; + [self.result.itemProvider + loadObjectOfClass:[UIImage class] + completionHandler:^(__kindof id _Nullable image, + NSError *_Nullable error) { + if ([image isKindOfClass:[UIImage class]]) { + __block UIImage *localImage = image; + PHAsset *originalAsset = + [FLTImagePickerPhotoAssetUtil getAssetFromPHPickerResult:self.result]; + + if (self.maxWidth != (id)[NSNull null] || self.maxHeight != (id)[NSNull null]) { + localImage = [FLTImagePickerImageUtil scaledImage:localImage + maxWidth:self.maxWidth + maxHeight:self.maxHeight + isMetadataAvailable:originalAsset != nil]; + } + __block NSString *savedPath; + if (!originalAsset) { + // Image picked without an original asset (e.g. User pick image without permission) + savedPath = + [FLTImagePickerPhotoAssetUtil saveImageWithPickerInfo:nil + image:localImage + imageQuality:self.desiredImageQuality]; + [self completeOperationWithPath:savedPath]; + } else { + [[PHImageManager defaultManager] + requestImageDataForAsset:originalAsset + options:nil + resultHandler:^( + NSData *_Nullable imageData, NSString *_Nullable dataUTI, + UIImageOrientation orientation, NSDictionary *_Nullable info) { + // maxWidth and maxHeight are used only for GIF images. + savedPath = [FLTImagePickerPhotoAssetUtil + saveImageWithOriginalImageData:imageData + image:localImage + maxWidth:self.maxWidth + maxHeight:self.maxHeight + imageQuality:self.desiredImageQuality]; + [self completeOperationWithPath:savedPath]; + }]; + } + } + }]; + } else { + [self setFinished:YES]; + } +} + +@end diff --git a/packages/image_picker/image_picker/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_for_web/example/README.md b/packages/image_picker/image_picker_for_web/example/README.md new file mode 100644 index 000000000000..4348451b14e2 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/README.md @@ -0,0 +1,9 @@ +# Testing + +This package uses `package:integration_test` to run its tests in a web browser. + +See [Plugin Tests > Web Tests](https://github.com/flutter/flutter/wiki/Plugin-Tests#web-tests) +in the Flutter wiki for instructions to setup and run the tests in this package. + +Check [flutter.dev > Integration testing](https://flutter.dev/docs/testing/integration-tests) +for more info. diff --git a/packages/image_picker/image_picker_for_web/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 new file mode 100644 index 000000000000..c1025a9f07d3 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart @@ -0,0 +1,173 @@ +// Copyright 2013 The Flutter 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:html' as html; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker_for_web/image_picker_for_web.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +final String expectedStringContents = 'Hello, world!'; +final String otherStringContents = 'Hello again, world!'; +final Uint8List bytes = utf8.encode(expectedStringContents) as Uint8List; +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(); + + // Under test... + late ImagePickerPlugin plugin; + + setUp(() { + plugin = ImagePickerPlugin(); + }); + + testWidgets('Can select a file (Deprecated)', (WidgetTester tester) async { + final mockInput = html.FileUploadInputElement(); + + final overrides = ImagePickerPluginTestOverrides() + ..createInputElement = ((_, __) => mockInput) + ..getMultipleFilesFromInput = ((_) => [textFile]); + + final plugin = ImagePickerPlugin(overrides: overrides); + + // Init the pick file dialog... + final file = plugin.pickFile(); + + // Mock the browser behavior of selecting a file... + mockInput.dispatchEvent(html.Event('change')); + + // Now the file should be available + expect(file, completes); + // And readable + expect((await file).readAsBytes(), completion(isNotEmpty)); + }); + + testWidgets('Can select a file', (WidgetTester tester) async { + final mockInput = html.FileUploadInputElement(); + + final overrides = ImagePickerPluginTestOverrides() + ..createInputElement = ((_, __) => mockInput) + ..getMultipleFilesFromInput = ((_) => [textFile]); + + final plugin = ImagePickerPlugin(overrides: overrides); + + // Init the pick file dialog... + 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(image, completes); + + // And readable + 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. + + testWidgets('computeCaptureAttribute', (WidgetTester tester) async { + expect( + plugin.computeCaptureAttribute(ImageSource.gallery, CameraDevice.front), + isNull, + ); + expect( + plugin.computeCaptureAttribute(ImageSource.gallery, CameraDevice.rear), + isNull, + ); + expect( + plugin.computeCaptureAttribute(ImageSource.camera, CameraDevice.front), + 'user', + ); + expect( + plugin.computeCaptureAttribute(ImageSource.camera, CameraDevice.rear), + 'environment', + ); + }); + + group('createInputElement', () { + testWidgets('accept: any, capture: null', (WidgetTester tester) async { + html.Element input = plugin.createInputElement('any', null); + + expect(input.attributes, containsPair('accept', 'any')); + expect(input.attributes, isNot(contains('capture'))); + expect(input.attributes, isNot(contains('multiple'))); + }); + + testWidgets('accept: any, capture: something', (WidgetTester tester) async { + html.Element input = plugin.createInputElement('any', 'something'); + + 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/example/integration_test/image_resizer_test.dart b/packages/image_picker/image_picker_for_web/example/integration_test/image_resizer_test.dart new file mode 100644 index 000000000000..067c7750eb11 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/integration_test/image_resizer_test.dart @@ -0,0 +1,128 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:html' as html; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker_for_web/src/image_resizer.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +//This is a sample 10x10 png image +final String pngFileBase64Contents = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKAQMAAAC3/F3+AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABlBMVEXqQzX+/v6lfubTAAAAAWJLR0QB/wIt3gAAAAlwSFlzAAAHEwAABxMBziAPCAAAAAd0SU1FB+UJHgsdDM0ErZoAAAALSURBVAjXY2DABwAAHgABboVHMgAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMS0wOS0zMFQxMToyOToxMi0wNDowMHCDC24AAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjEtMDktMzBUMTE6Mjk6MTItMDQ6MDAB3rPSAAAAAElFTkSuQmCC"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // Under test... + late ImageResizer imageResizer; + late XFile pngFile; + setUp(() { + imageResizer = ImageResizer(); + final pngHtmlFile = _base64ToFile(pngFileBase64Contents, "pngImage.png"); + pngFile = XFile(html.Url.createObjectUrl(pngHtmlFile), + name: pngHtmlFile.name, mimeType: pngHtmlFile.type); + }); + + testWidgets("image is loaded correctly ", (WidgetTester tester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + expect(imageElement.width!, 10); + expect(imageElement.height!, 10); + }); + + testWidgets( + "canvas is loaded with image's width and height when max width and max height are null", + (widgetTester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + final canvas = imageResizer.resizeImageElement(imageElement, null, null); + expect(canvas.width, imageElement.width); + expect(canvas.height, imageElement.height); + }); + + testWidgets( + "canvas size is scaled when max width and max height are not null", + (widgetTester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + final canvas = imageResizer.resizeImageElement(imageElement, 8, 8); + expect(canvas.width, 8); + expect(canvas.height, 8); + }); + + testWidgets("resized image is returned after converting canvas to file", + (widgetTester) async { + final imageElement = await imageResizer.loadImage(pngFile.path); + final canvas = imageResizer.resizeImageElement(imageElement, null, null); + final resizedImage = + await imageResizer.writeCanvasToFile(pngFile, canvas, null); + expect(resizedImage.name, "scaled_${pngFile.name}"); + }); + + testWidgets("image is scaled when maxWidth is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, 5, null, null); + expect(scaledImage.name, "scaled_${pngFile.name}"); + final scaledImageSize = await _getImageSize(scaledImage); + expect(scaledImageSize, Size(5, 5)); + }); + + testWidgets("image is scaled when maxHeight is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, null, 6, null); + expect(scaledImage.name, "scaled_${pngFile.name}"); + final scaledImageSize = await _getImageSize(scaledImage); + expect(scaledImageSize, Size(6, 6)); + }); + + testWidgets("image is scaled when imageQuality is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, null, null, 89); + expect(scaledImage.name, "scaled_${pngFile.name}"); + }); + + testWidgets("image is scaled when maxWidth,maxHeight,imageQuality are set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, 3, 4, 89); + expect(scaledImage.name, "scaled_${pngFile.name}"); + }); + + testWidgets("image is not scaled when maxWidth,maxHeight, is set", + (WidgetTester tester) async { + final scaledImage = + await imageResizer.resizeImageIfNeeded(pngFile, null, null, null); + expect(scaledImage.name, pngFile.name); + }); +} + +Future _getImageSize(XFile file) async { + final completer = Completer(); + final image = html.ImageElement(src: file.path); + image.onLoad.listen((event) { + completer.complete(Size(image.width!.toDouble(), image.height!.toDouble())); + }); + image.onError.listen((event) { + completer.complete(Size(0, 0)); + }); + return completer.future; +} + +html.File _base64ToFile(String data, String fileName) { + var arr = data.split(','); + var bstr = html.window.atob(arr[1]); + var n = bstr.length, u8arr = Uint8List(n); + + while (n >= 1) { + u8arr[n - 1] = bstr.codeUnitAt(n - 1); + n--; + } + + return html.File([u8arr], fileName); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/lib/main.dart b/packages/image_picker/image_picker_for_web/example/lib/main.dart similarity index 75% rename from packages/google_maps_flutter/google_maps_flutter_web/example/lib/main.dart rename to packages/image_picker/image_picker_for_web/example/lib/main.dart index 10415204570c..e1a38dcdcd46 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/lib/main.dart +++ b/packages/image_picker/image_picker_for_web/example/lib/main.dart @@ -17,6 +17,9 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { @override Widget build(BuildContext context) { - return Text('Testing... Look at the console output for results!'); + return Directionality( + textDirection: TextDirection.ltr, + child: Text('Testing... Look at the console output for results!'), + ); } } diff --git a/packages/image_picker/image_picker_for_web/example/pubspec.yaml b/packages/image_picker/image_picker_for_web/example/pubspec.yaml new file mode 100644 index 000000000000..8dadde267e8a --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: connectivity_for_web_integration_tests +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.2.0" + +dependencies: + image_picker_for_web: + path: ../ + flutter: + sdk: flutter + +dev_dependencies: + js: ^0.6.3 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/run_test.sh b/packages/image_picker/image_picker_for_web/example/run_test.sh similarity index 97% rename from packages/google_maps_flutter/google_maps_flutter_web/example/run_test.sh rename to packages/image_picker/image_picker_for_web/example/run_test.sh index fcac5f600acb..aa52974f310e 100755 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/run_test.sh +++ b/packages/image_picker/image_picker_for_web/example/run_test.sh @@ -6,8 +6,6 @@ if pgrep -lf chromedriver > /dev/null; then echo "chromedriver is running." - ./regen_mocks.sh - 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='{}' diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/test_driver/integration_test.dart b/packages/image_picker/image_picker_for_web/example/test_driver/integration_test.dart similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter_web/example/test_driver/integration_test.dart rename to packages/image_picker/image_picker_for_web/example/test_driver/integration_test.dart diff --git a/packages/image_picker/image_picker_for_web/example/web/index.html b/packages/image_picker/image_picker_for_web/example/web/index.html new file mode 100644 index 000000000000..7fb138cc90fa --- /dev/null +++ b/packages/image_picker/image_picker_for_web/example/web/index.html @@ -0,0 +1,13 @@ + + + + + + example + + + + + diff --git a/packages/image_picker/image_picker_for_web/lib/src/image_resizer.dart b/packages/image_picker/image_picker_for_web/lib/src/image_resizer.dart new file mode 100644 index 000000000000..6ee7c5f015e2 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/lib/src/image_resizer.dart @@ -0,0 +1,83 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; +import 'package:image_picker_for_web/src/image_resizer_utils.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'dart:html' as html; + +/// Helper class that resizes images. +class ImageResizer { + /// Resizes the image if needed. + /// (Does not support gif images) + Future resizeImageIfNeeded(XFile file, double? maxWidth, + double? maxHeight, int? imageQuality) async { + if (!imageResizeNeeded(maxWidth, maxHeight, imageQuality) || + file.mimeType == "image/gif") { + // Implement maxWidth and maxHeight for image/gif + return file; + } + try { + final imageElement = await loadImage(file.path); + final canvas = resizeImageElement(imageElement, maxWidth, maxHeight); + final resizedImage = await writeCanvasToFile(file, canvas, imageQuality); + html.Url.revokeObjectUrl(file.path); + return resizedImage; + } catch (e) { + return file; + } + } + + /// function that loads the blobUrl into an imageElement + Future loadImage(String blobUrl) { + final imageLoadCompleter = Completer(); + final imageElement = html.ImageElement(); + imageElement.src = blobUrl; + + imageElement.onLoad.listen((event) { + imageLoadCompleter.complete(imageElement); + }); + imageElement.onError.listen((event) { + final exception = ("Error while loading image."); + imageElement.remove(); + imageLoadCompleter.completeError(exception); + }); + return imageLoadCompleter.future; + } + + /// Draws image to a canvas while resizing the image to fit the [maxWidth],[maxHeight] constraints + html.CanvasElement resizeImageElement( + html.ImageElement source, double? maxWidth, double? maxHeight) { + final newImageSize = calculateSizeOfDownScaledImage( + Size(source.width!.toDouble(), source.height!.toDouble()), + maxWidth, + maxHeight); + final canvas = html.CanvasElement(); + canvas.width = newImageSize.width.toInt(); + canvas.height = newImageSize.height.toInt(); + final context = canvas.context2D; + if (maxHeight == null && maxWidth == null) { + context.drawImage(source, 0, 0); + } else { + context.drawImageScaled(source, 0, 0, canvas.width!, canvas.height!); + } + return canvas; + } + + /// function that converts a canvas element to Xfile + /// [imageQuality] is only supported for jpeg and webp images. + Future writeCanvasToFile( + XFile originalFile, html.CanvasElement canvas, int? imageQuality) async { + final calculatedImageQuality = ((min(imageQuality ?? 100, 100)) / 100.0); + final blob = + await canvas.toBlob(originalFile.mimeType, calculatedImageQuality); + return XFile(html.Url.createObjectUrlFromBlob(blob), + mimeType: originalFile.mimeType, + name: "scaled_" + originalFile.name, + lastModified: DateTime.now(), + length: blob.size); + } +} diff --git a/packages/image_picker/image_picker_for_web/lib/src/image_resizer_utils.dart b/packages/image_picker/image_picker_for_web/lib/src/image_resizer_utils.dart new file mode 100644 index 000000000000..6ef789254b3f --- /dev/null +++ b/packages/image_picker/image_picker_for_web/lib/src/image_resizer_utils.dart @@ -0,0 +1,33 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +///a function that checks if an image needs to be resized or not +bool imageResizeNeeded(double? maxWidth, double? maxHeight, int? imageQuality) { + return imageQuality != null + ? isImageQualityValid(imageQuality) + : (maxWidth != null || maxHeight != null); +} + +/// a function that checks if image quality is between 0 to 100 +bool isImageQualityValid(int imageQuality) { + return (imageQuality >= 0 && imageQuality <= 100); +} + +/// a function that calculates the size of the downScaled image. +/// imageWidth is the width of the image +/// imageHeight is the height of the image +/// maxWidth is the maximum width of the scaled image +/// maxHeight is the maximum height of the scaled image +Size calculateSizeOfDownScaledImage( + Size imageSize, double? maxWidth, double? maxHeight) { + double widthFactor = maxWidth != null ? imageSize.width / maxWidth : 1; + double heightFactor = maxHeight != null ? imageSize.height / maxHeight : 1; + double resizeFactor = max(widthFactor, heightFactor); + return (resizeFactor > 1 ? imageSize ~/ resizeFactor : imageSize); +} diff --git a/packages/image_picker/image_picker_for_web/test/README.md b/packages/image_picker/image_picker_for_web/test/README.md new file mode 100644 index 000000000000..7c5b4ad682ba --- /dev/null +++ b/packages/image_picker/image_picker_for_web/test/README.md @@ -0,0 +1,5 @@ +## test + +This package uses integration tests for testing. + +See `example/README.md` for more info. diff --git a/packages/image_picker/image_picker_for_web/test/image_resizer_utils_test.dart b/packages/image_picker/image_picker_for_web/test/image_resizer_utils_test.dart new file mode 100644 index 000000000000..352d2bea48a5 --- /dev/null +++ b/packages/image_picker/image_picker_for_web/test/image_resizer_utils_test.dart @@ -0,0 +1,92 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'dart:ui'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:image_picker_for_web/src/image_resizer_utils.dart'; + +void main() { + group('Image Resizer Utils', () { + group("calculateSizeOfScaledImage", () { + test( + "scaled image height and width are same if max width and max height are same as image's width and height", + () { + expect(calculateSizeOfDownScaledImage(Size(500, 300), 500, 300), + Size(500, 300)); + }); + + test( + "scaled image height and width are same if max width and max height are null", + () { + expect(calculateSizeOfDownScaledImage(Size(500, 300), null, null), + Size(500, 300)); + }); + + test("image size is scaled when maxWidth is set", () { + final imageSize = Size(500, 300); + final maxWidth = 400; + final scaledSize = calculateSizeOfDownScaledImage( + Size(imageSize.width, imageSize.height), maxWidth.toDouble(), null); + expect(scaledSize.height <= imageSize.height, true); + expect(scaledSize.width <= maxWidth, true); + }); + + test("image size is scaled when maxHeight is set", () { + final imageSize = Size(500, 300); + final maxHeight = 400; + final scaledSize = calculateSizeOfDownScaledImage( + Size(imageSize.width, imageSize.height), + null, + maxHeight.toDouble()); + expect(scaledSize.height <= maxHeight, true); + expect(scaledSize.width <= imageSize.width, true); + }); + + test("image size is scaled when both maxWidth and maxHeight is set", () { + final imageSize = Size(1120, 2000); + final maxHeight = 1200; + final maxWidth = 99; + final scaledSize = calculateSizeOfDownScaledImage( + Size(imageSize.width, imageSize.height), + maxWidth.toDouble(), + maxHeight.toDouble()); + expect(scaledSize.height <= maxHeight, true); + expect(scaledSize.width <= maxWidth, true); + }); + }); + group("imageResizeNeeded", () { + test("image needs to be resized when maxWidth is set", () { + expect(imageResizeNeeded(50, null, null), true); + }); + + test("image needs to be resized when maxHeight is set", () { + expect(imageResizeNeeded(null, 50, null), true); + }); + + test("image needs to be resized when imageQuality is set", () { + expect(imageResizeNeeded(null, null, 100), true); + }); + + test("image will not be resized when imageQuality is not valid", () { + expect(imageResizeNeeded(null, null, 101), false); + expect(imageResizeNeeded(null, null, -1), false); + }); + }); + + group("isImageQualityValid", () { + test("image quality is valid in 0 to 100", () { + expect(isImageQualityValid(50), true); + expect(isImageQualityValid(0), true); + expect(isImageQualityValid(100), true); + }); + + test( + "image quality is not valid when imageQuality is less than 0 or greater than 100", + () { + expect(isImageQualityValid(-1), false); + expect(isImageQualityValid(101), false); + }); + }); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/test/tests_exist_elsewhere_test.dart b/packages/image_picker/image_picker_for_web/test/tests_exist_elsewhere_test.dart similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter_web/test/tests_exist_elsewhere_test.dart rename to packages/image_picker/image_picker_for_web/test/tests_exist_elsewhere_test.dart diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart new file mode 100644 index 000000000000..65f5d7e15c90 --- /dev/null +++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart @@ -0,0 +1,66 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:cross_file/cross_file.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker_platform_interface/src/types/types.dart'; + +/// The response object of [ImagePicker.getLostData]. +/// +/// Only applies to Android. +/// See also: +/// * [ImagePicker.getLostData] for more details on retrieving lost data. +class LostDataResponse { + /// Creates an instance with the given [file], [exception], and [type]. Any of + /// the params may be null, but this is never considered to be empty. + LostDataResponse({ + this.file, + this.exception, + this.type, + this.files, + }); + + /// Initializes an instance with all member params set to null and considered + /// to be empty. + LostDataResponse.empty() + : file = null, + exception = null, + type = null, + _empty = true, + files = null; + + /// Whether it is an empty response. + /// + /// An empty response should have [file], [exception] and [type] to be null. + bool get isEmpty => _empty; + + /// The file that was lost in a previous [getImage], [getMultiImage] or [getVideo] call due to MainActivity being destroyed. + /// + /// Can be null if [exception] exists. + final XFile? file; + + /// The exception of the last [getImage], [getMultiImage] or [getVideo]. + /// + /// If the last [getImage], [getMultiImage] or [getVideo] threw some exception before the MainActivity destruction, + /// this variable keeps that exception. + /// You should handle this exception as if the [getImage], [getMultiImage] or [getVideo] got an exception when + /// the MainActivity was not destroyed. + /// + /// Note that it is not the exception that caused the destruction of the MainActivity. + final PlatformException? exception; + + /// Can either be [RetrieveType.image] or [RetrieveType.video]; + /// + /// If the lost data is empty, this will be null. + final RetrieveType? type; + + bool _empty = false; + + /// 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/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/in_app_purchase/in_app_purchase_android/AUTHORS b/packages/in_app_purchase/in_app_purchase_android/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart new file mode 100644 index 000000000000..efe7656d2138 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'billing_client_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +const _$BillingResponseEnumMap = { + BillingResponse.serviceTimeout: -3, + BillingResponse.featureNotSupported: -2, + BillingResponse.serviceDisconnected: -1, + BillingResponse.ok: 0, + BillingResponse.userCanceled: 1, + BillingResponse.serviceUnavailable: 2, + BillingResponse.billingUnavailable: 3, + BillingResponse.itemUnavailable: 4, + BillingResponse.developerError: 5, + BillingResponse.error: 6, + BillingResponse.itemAlreadyOwned: 7, + BillingResponse.itemNotOwned: 8, +}; + +const _$SkuTypeEnumMap = { + SkuType.inapp: 'inapp', + SkuType.subs: 'subs', +}; + +const _$ProrationModeEnumMap = { + ProrationMode.unknownSubscriptionUpgradeDowngradePolicy: 0, + ProrationMode.immediateWithTimeProration: 1, + ProrationMode.immediateAndChargeProratedPrice: 2, + ProrationMode.immediateWithoutProration: 3, + ProrationMode.deferred: 4, +}; + +const _$BillingClientFeatureEnumMap = { + BillingClientFeature.inAppItemsOnVR: 'inAppItemsOnVr', + BillingClientFeature.priceChangeConfirmation: 'priceChangeConfirmation', + BillingClientFeature.subscriptions: 'subscriptions', + BillingClientFeature.subscriptionsOnVR: 'subscriptionsOnVr', + BillingClientFeature.subscriptionsUpdate: 'subscriptionsUpdate', +}; diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart new file mode 100644 index 000000000000..3e29d92724ad --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_deprecated_test.dart @@ -0,0 +1,69 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// TODO(mvanbeusekom): Remove this file when the deprecated +// `SkuDetailsWrapper.introductoryPriceMicros` field is +// removed. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; + +void main() { + test( + 'Deprecated `introductoryPriceMicros` field reflects parameter from constructor', + () { + final SkuDetailsWrapper skuDetails = SkuDetailsWrapper( + description: 'description', + freeTrialPeriod: 'freeTrialPeriod', + introductoryPrice: 'introductoryPrice', + // ignore: deprecated_member_use_from_same_package + introductoryPriceMicros: '990000', + introductoryPriceCycles: 1, + introductoryPricePeriod: 'introductoryPricePeriod', + price: 'price', + priceAmountMicros: 1000, + priceCurrencyCode: 'priceCurrencyCode', + priceCurrencySymbol: r'$', + sku: 'sku', + subscriptionPeriod: 'subscriptionPeriod', + title: 'title', + type: SkuType.inapp, + originalPrice: 'originalPrice', + originalPriceAmountMicros: 1000, + ); + + expect(skuDetails, isNotNull); + expect(skuDetails.introductoryPriceAmountMicros, 0); + // ignore: deprecated_member_use_from_same_package + expect(skuDetails.introductoryPriceMicros, '990000'); + }); + + test( + '`introductoryPriceAmoutMicros` constructor parameter is reflected by deprecated `introductoryPriceMicros` and `introductoryPriceAmountMicros` fields', + () { + final SkuDetailsWrapper skuDetails = SkuDetailsWrapper( + description: 'description', + freeTrialPeriod: 'freeTrialPeriod', + introductoryPrice: 'introductoryPrice', + introductoryPriceAmountMicros: 990000, + introductoryPriceCycles: 1, + introductoryPricePeriod: 'introductoryPricePeriod', + price: 'price', + priceAmountMicros: 1000, + priceCurrencyCode: 'priceCurrencyCode', + priceCurrencySymbol: r'$', + sku: 'sku', + subscriptionPeriod: 'subscriptionPeriod', + title: 'title', + type: SkuType.inapp, + originalPrice: 'originalPrice', + originalPriceAmountMicros: 1000, + ); + + expect(skuDetails, isNotNull); + expect(skuDetails.introductoryPriceAmountMicros, 990000); + // ignore: deprecated_member_use_from_same_package + expect(skuDetails.introductoryPriceMicros, '990000'); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/AUTHORS b/packages/in_app_purchase/in_app_purchase_ios/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m new file mode 100644 index 000000000000..810e1fafe11a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/example/ios/RunnerTests/FIAPPaymentQueueDeleteTests.m @@ -0,0 +1,120 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import "FIAObjectTranslator.h" +#import "FIAPaymentQueueHandler.h" +#import "Stubs.h" + +@import in_app_purchase_ios; + +API_AVAILABLE(ios(13.0)) +@interface FIAPPaymentQueueDelegateTests : XCTestCase + +@property(strong, nonatomic) FlutterMethodChannel *channel; +@property(strong, nonatomic) SKPaymentTransaction *transaction; +@property(strong, nonatomic) SKStorefront *storefront; + +@end + +@implementation FIAPPaymentQueueDelegateTests + +- (void)setUp { + self.channel = OCMClassMock(FlutterMethodChannel.class); + + NSDictionary *transactionMap = @{ + @"transactionIdentifier" : [NSNull null], + @"transactionState" : @(SKPaymentTransactionStatePurchasing), + @"payment" : [NSNull null], + @"error" : [FIAObjectTranslator getMapFromNSError:[NSError errorWithDomain:@"test_stub" + code:123 + userInfo:@{}]], + @"transactionTimeStamp" : @([NSDate date].timeIntervalSince1970), + @"originalTransaction" : [NSNull null], + }; + self.transaction = [[SKPaymentTransactionStub alloc] initWithMap:transactionMap]; + + NSDictionary *storefrontMap = @{ + @"countryCode" : @"USA", + @"identifier" : @"unique_identifier", + }; + self.storefront = [[SKStorefrontStub alloc] initWithMap:storefrontMap]; +} + +- (void)tearDown { + self.channel = nil; +} + +- (void)testShouldContinueTransaction { + if (@available(iOS 13.0, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel + invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:self.storefront + andSKPaymentTransaction:self.transaction] + result:([OCMArg invokeBlockWithArgs:[NSNumber numberWithBool:NO], nil])]); + + BOOL shouldContinue = [delegate paymentQueue:OCMClassMock(SKPaymentQueue.class) + shouldContinueTransaction:self.transaction + inStorefront:self.storefront]; + + XCTAssertFalse(shouldContinue); + } +} + +- (void)testShouldContinueTransaction_should_default_to_yes { + if (@available(iOS 13.0, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:self.storefront + andSKPaymentTransaction:self.transaction] + result:[OCMArg any]]); + + BOOL shouldContinue = [delegate paymentQueue:OCMClassMock(SKPaymentQueue.class) + shouldContinueTransaction:self.transaction + inStorefront:self.storefront]; + + XCTAssertTrue(shouldContinue); + } +} + +- (void)testShouldShowPriceConsentIfNeeded { + if (@available(iOS 13.4, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel + invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:([OCMArg invokeBlockWithArgs:[NSNumber numberWithBool:NO], nil])]); + + BOOL shouldShow = + [delegate paymentQueueShouldShowPriceConsent:OCMClassMock(SKPaymentQueue.class)]; + + XCTAssertFalse(shouldShow); + } +} + +- (void)testShouldShowPriceConsentIfNeeded_should_default_to_yes { + if (@available(iOS 13.4, *)) { + FIAPPaymentQueueDelegate *delegate = + [[FIAPPaymentQueueDelegate alloc] initWithMethodChannel:self.channel]; + + OCMStub([self.channel invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:[OCMArg any]]); + + BOOL shouldShow = + [delegate paymentQueueShouldShowPriceConsent:OCMClassMock(SKPaymentQueue.class)]; + + XCTAssertTrue(shouldShow); + } +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart b/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart new file mode 100644 index 000000000000..dfebdf9cdf98 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/example/lib/example_payment_queue_delegate.dart @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +/// Example implementation of the +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper { + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + return true; + } + + @override + bool shouldShowPriceConsent() { + return false; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h new file mode 100644 index 000000000000..a6c91fa9e6b6 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.h @@ -0,0 +1,16 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +API_AVAILABLE(ios(13)) +@interface FIAPPaymentQueueDelegate : NSObject +- (id)initWithMethodChannel:(FlutterMethodChannel *)methodChannel; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m new file mode 100644 index 000000000000..1056086030a5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/ios/Classes/FIAPPaymentQueueDelegate.m @@ -0,0 +1,78 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FIAPPaymentQueueDelegate.h" +#import "FIAObjectTranslator.h" + +@interface FIAPPaymentQueueDelegate () + +@property(strong, nonatomic, readonly) FlutterMethodChannel *callbackChannel; + +@end + +@implementation FIAPPaymentQueueDelegate + +- (id)initWithMethodChannel:(FlutterMethodChannel *)methodChannel { + self = [super init]; + if (self) { + _callbackChannel = methodChannel; + } + + return self; +} + +- (BOOL)paymentQueue:(SKPaymentQueue *)paymentQueue + shouldContinueTransaction:(SKPaymentTransaction *)transaction + inStorefront:(SKStorefront *)newStorefront { + // Default return value for this method is true (see + // https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc) + __block BOOL shouldContinue = YES; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [self.callbackChannel invokeMethod:@"shouldContinueTransaction" + arguments:[FIAObjectTranslator getMapFromSKStorefront:newStorefront + andSKPaymentTransaction:transaction] + result:^(id _Nullable result) { + // When result is a valid instance of NSNumber use it to determine + // if the transaction should continue. Otherwise use the default + // value. + if (result && [result isKindOfClass:[NSNumber class]]) { + shouldContinue = [(NSNumber *)result boolValue]; + } + + dispatch_semaphore_signal(semaphore); + }]; + + // The client should respond within 1 second otherwise continue + // with default value. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + + return shouldContinue; +} + +- (BOOL)paymentQueueShouldShowPriceConsent:(SKPaymentQueue *)paymentQueue { + // Default return value for this method is true (see + // https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc) + __block BOOL shouldShowPriceConsent = YES; + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + [self.callbackChannel invokeMethod:@"shouldShowPriceConsent" + arguments:nil + result:^(id _Nullable result) { + // When result is a valid instance of NSNumber use it to determine + // if the transaction should continue. Otherwise use the default + // value. + if (result && [result isKindOfClass:[NSNumber class]]) { + shouldShowPriceConsent = [(NSNumber *)result boolValue]; + } + + dispatch_semaphore_signal(semaphore); + }]; + + // The client should respond within 1 second otherwise continue + // with default value. + dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + + return shouldShowPriceConsent; +} + +@end diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart new file mode 100644 index 000000000000..2759a296389b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_payment_queue_delegate_wrapper.dart @@ -0,0 +1,39 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +/// A wrapper around +/// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc). +/// +/// The payment queue delegate can be implementated to provide information +/// needed to complete transactions. +/// +/// The [SKPaymentQueueDelegateWrapper] is only available on iOS 13 and higher. +/// Using the delegate on older iOS version will be ignored. +abstract class SKPaymentQueueDelegateWrapper { + /// Called by the system to check whether the transaction should continue if + /// the device's App Store storefront has changed during a transaction. + /// + /// - Return `true` if the transaction should continue within the updated + /// storefront (default behaviour). + /// - Return `false` if the transaction should be cancelled. In this case the + /// transaction will fail with the error [SKErrorStoreProductNotAvailable](https://developer.apple.com/documentation/storekit/skerrorcode/skerrorstoreproductnotavailable?language=objc). + /// + /// See the documentation in StoreKit's [`[-SKPaymentQueueDelegate shouldContinueTransaction]`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3242935-paymentqueue?language=objc). + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, + SKStorefrontWrapper storefront, + ) => + true; + + /// Called by the system to check whether to immediately show the price + /// consent form. + /// + /// The default return value is `true`. This will inform the system to display + /// the price consent sheet when the subscription price has been changed in + /// App Store Connect and the subscriber has not yet taken action. See the + /// documentation in StoreKit's [`[-SKPaymentQueueDelegate shouldShowPriceConsent:]`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate/3521328-paymentqueueshouldshowpriceconse?language=objc). + bool shouldShowPriceConsent() => true; +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart new file mode 100644 index 000000000000..bcdae3206c00 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.dart @@ -0,0 +1,65 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show hashValues; + +import 'package:json_annotation/json_annotation.dart'; + +part 'sk_storefront_wrapper.g.dart'; + +/// Contains the location and unique identifier of an Apple App Store storefront. +/// +/// Dart wrapper around StoreKit's +/// [SKStorefront](https://developer.apple.com/documentation/storekit/skstorefront?language=objc). +@JsonSerializable(createToJson: true) +class SKStorefrontWrapper { + /// Creates a new [SKStorefrontWrapper] with the provided information. + SKStorefrontWrapper({ + required this.countryCode, + required this.identifier, + }); + + /// Constructs an instance of the [SKStorefrontWrapper] from a key value map + /// of data. + /// + /// The map needs to have named string keys with values matching the names and + /// types of all of the members on this class. The `map` parameter must not be + /// null. + factory SKStorefrontWrapper.fromJson(Map map) { + return _$SKStorefrontWrapperFromJson(map); + } + + /// The three-letter code representing the country or region associated with + /// the App Store storefront. + final String countryCode; + + /// A value defined by Apple that uniquely identifies an App Store storefront. + final String identifier; + + @override + bool operator ==(Object other) { + if (identical(other, this)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + final SKStorefrontWrapper typedOther = other as SKStorefrontWrapper; + return typedOther.countryCode == countryCode && + typedOther.identifier == identifier; + } + + @override + int get hashCode => hashValues( + this.countryCode, + this.identifier, + ); + + @override + String toString() => _$SKStorefrontWrapperToJson(this).toString(); + + /// Converts the instance to a key value map which can be used to serialize + /// to JSON format. + Map toMap() => _$SKStorefrontWrapperToJson(this); +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart new file mode 100644 index 000000000000..b2d5d3a06d1d --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/sk_storefront_wrapper.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sk_storefront_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SKStorefrontWrapper _$SKStorefrontWrapperFromJson(Map json) => + SKStorefrontWrapper( + countryCode: json['countryCode'] as String, + identifier: json['identifier'] as String, + ); + +Map _$SKStorefrontWrapperToJson( + SKStorefrontWrapper instance) => + { + 'countryCode': instance.countryCode, + 'identifier': instance.identifier, + }; diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart new file mode 100644 index 000000000000..ca2b3364d680 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_ios/test/store_kit_wrappers/sk_payment_queue_delegate_api_test.dart @@ -0,0 +1,168 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_ios/src/channel.dart'; +import 'package:in_app_purchase_ios/store_kit_wrappers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final FakeIOSPlatform fakeIOSPlatform = FakeIOSPlatform(); + + setUpAll(() { + SystemChannels.platform + .setMockMethodCallHandler(fakeIOSPlatform.onMethodCall); + }); + + test( + 'handlePaymentQueueDelegateCallbacks should call SKPaymentQueueDelegateWrapper.shouldContinueTransaction', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentQueueDelegate testDelegate = TestPaymentQueueDelegate(); + await queue.setDelegate(testDelegate); + + final Map arguments = { + 'storefront': { + 'countryCode': 'USA', + 'identifier': 'unique_identifier', + }, + 'transaction': { + 'payment': { + 'productIdentifier': 'product_identifier', + } + }, + }; + + final result = await queue.handlePaymentQueueDelegateCallbacks( + MethodCall('shouldContinueTransaction', arguments), + ); + + expect(result, false); + expect( + testDelegate.log, + { + equals('shouldContinueTransaction'), + }, + ); + }); + + test( + 'handlePaymentQueueDelegateCallbacks should call SKPaymentQueueDelegateWrapper.shouldShowPriceConsent', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestPaymentQueueDelegate testDelegate = TestPaymentQueueDelegate(); + await queue.setDelegate(testDelegate); + + final result = await queue.handlePaymentQueueDelegateCallbacks( + MethodCall('shouldShowPriceConsent'), + ); + + expect(result, false); + expect( + testDelegate.log, + { + equals('shouldShowPriceConsent'), + }, + ); + }); + + test( + 'handleObserverCallbacks should call SKTransactionObserverWrapper.restoreCompletedTransactionsFailed', + () async { + SKPaymentQueueWrapper queue = SKPaymentQueueWrapper(); + TestTransactionObserverWrapper testObserver = + TestTransactionObserverWrapper(); + queue.setTransactionObserver(testObserver); + + final arguments = { + 'code': 100, + 'domain': 'domain', + 'userInfo': {'error': 'underlying_error'}, + }; + + await queue.handleObserverCallbacks( + MethodCall('restoreCompletedTransactionsFailed', arguments), + ); + + expect( + testObserver.log, + { + equals('restoreCompletedTransactionsFailed'), + }, + ); + }); +} + +class TestTransactionObserverWrapper extends SKTransactionObserverWrapper { + final List log = []; + + @override + void updatedTransactions( + {required List transactions}) { + log.add('updatedTransactions'); + } + + @override + void removedTransactions( + {required List transactions}) { + log.add('removedTransactions'); + } + + @override + void restoreCompletedTransactionsFailed({required SKError error}) { + log.add('restoreCompletedTransactionsFailed'); + } + + @override + void paymentQueueRestoreCompletedTransactionsFinished() { + log.add('paymentQueueRestoreCompletedTransactionsFinished'); + } + + @override + bool shouldAddStorePayment( + {required SKPaymentWrapper payment, required SKProductWrapper product}) { + log.add('shouldAddStorePayment'); + return false; + } +} + +class TestPaymentQueueDelegate extends SKPaymentQueueDelegateWrapper { + final List log = []; + + @override + bool shouldContinueTransaction( + SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) { + log.add('shouldContinueTransaction'); + return false; + } + + @override + bool shouldShowPriceConsent() { + log.add('shouldShowPriceConsent'); + return false; + } +} + +class FakeIOSPlatform { + FakeIOSPlatform() { + channel.setMockMethodCallHandler(onMethodCall); + } + + // indicate if the payment queue delegate is registered + bool isPaymentQueueDelegateRegistered = false; + + Future onMethodCall(MethodCall call) { + switch (call.method) { + case '-[SKPaymentQueue registerDelegate]': + isPaymentQueueDelegateRegistered = true; + return Future.sync(() {}); + case '-[SKPaymentQueue removeDelegate]': + isPaymentQueueDelegateRegistered = false; + return Future.sync(() {}); + } + return Future.error('method not mocked'); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/AUTHORS b/packages/in_app_purchase/in_app_purchase_platform_interface/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_error.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_error.dart new file mode 100644 index 000000000000..166646d35b24 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_error.dart @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Captures an error from the underlying purchase platform. +/// +/// The error can happen during the purchase, restoring a purchase, or querying product. +/// Errors from restoring a purchase are not indicative of any errors during the original purchase. +/// See also: +/// * [ProductDetailsResponse] for error when querying product details. +/// * [PurchaseDetails] for error happened in purchase. +class IAPError { + /// Creates a new IAP error object with the given error details. + IAPError( + {required this.source, + required this.code, + required this.message, + this.details}); + + /// Which source is the error on. + final String source; + + /// The error code. + final String code; + + /// A human-readable error message. + final String message; + + /// 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/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/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/android/src/test/java/io/flutter/plugins/localauth/LocalAuthTest.java b/packages/local_auth/android/src/test/java/io/flutter/plugins/localauth/LocalAuthTest.java new file mode 100644 index 000000000000..522185fc9dd3 --- /dev/null +++ b/packages/local_auth/android/src/test/java/io/flutter/plugins/localauth/LocalAuthTest.java @@ -0,0 +1,22 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.localauth; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import org.junit.Test; + +public class LocalAuthTest { + @Test + public void isDeviceSupportedReturnsFalse() { + final LocalAuthPlugin plugin = new LocalAuthPlugin(); + final MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + plugin.onMethodCall(new MethodCall("isDeviceSupported", null), mockResult); + verify(mockResult).success(false); + } +} diff --git a/packages/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/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/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/io/flutter/plugins/pathprovider/MainActivityTest.java b/packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/pathprovider/MainActivityTest.java new file mode 100644 index 000000000000..d56458bd753c --- /dev/null +++ b/packages/path_provider/path_provider/example/android/app/src/androidTest/java/io/flutter/plugins/pathprovider/MainActivityTest.java @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.pathprovider; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@DartIntegrationTest +@RunWith(FlutterTestRunner.class) +public class MainActivityTest { + @Rule + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_web/AUTHORS b/packages/quick_actions/quick_actions/AUTHORS similarity index 98% rename from packages/google_maps_flutter/google_maps_flutter_web/AUTHORS rename to packages/quick_actions/quick_actions/AUTHORS index 493a0b4ef9c2..0ca697b6a756 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/AUTHORS +++ b/packages/quick_actions/quick_actions/AUTHORS @@ -64,3 +64,4 @@ Aleksandr Yurkovskiy Anton Borries Alex Li Rahul Raj <64.rahulraj@gmail.com> +Daniel Roek 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/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java new file mode 100644 index 000000000000..e96548da291a --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/FlutterActivityTest.java @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.quickactionsexample; + +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 + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/QuickActionsTest.java b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/QuickActionsTest.java new file mode 100644 index 000000000000..9d2fed13fc27 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/androidTest/java/io/flutter/plugins/quickactionsexample/QuickActionsTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.quickactionsexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.quickactions.QuickActionsPlugin; +import org.junit.Test; + +public class QuickActionsTest { + @Test + public void imagePickerPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(QuickActionsTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(QuickActionsPlugin.class)); + }); + } +} diff --git a/packages/quick_actions/quick_actions/example/android/app/src/debug/AndroidManifest.xml b/packages/quick_actions/quick_actions/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..bee689df1735 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/QuickActionsTestActivity.java b/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/QuickActionsTestActivity.java new file mode 100644 index 000000000000..4ff3a27cd5c0 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/android/app/src/main/java/io/flutter/plugins/quickactionsexample/QuickActionsTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.quickactionsexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Makes the FlutterEngine accessible for testing. +public class QuickActionsTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/quick_actions/quick_actions/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to packages/quick_actions/quick_actions/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/Info.plist b/packages/quick_actions/quick_actions/example/ios/RunnerTests/Info.plist similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/Info.plist rename to packages/quick_actions/quick_actions/example/ios/RunnerTests/Info.plist diff --git a/packages/quick_actions/quick_actions/example/ios/RunnerTests/RunnerTests.m b/packages/quick_actions/quick_actions/example/ios/RunnerTests/RunnerTests.m new file mode 100644 index 000000000000..64e0f7e1d8b2 --- /dev/null +++ b/packages/quick_actions/quick_actions/example/ios/RunnerTests/RunnerTests.m @@ -0,0 +1,18 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import quick_actions; +@import XCTest; + +@interface QuickActionsTests : XCTestCase +@end + +@implementation QuickActionsTests + +- (void)testPlugin { + FLTQuickActionsPlugin* plugin = [[FLTQuickActionsPlugin alloc] init]; + XCTAssertNotNil(plugin); +} + +@end diff --git a/packages/google_maps_flutter/google_maps_flutter_platform_interface/AUTHORS b/packages/quick_actions/quick_actions_platform_interface/AUTHORS similarity index 98% rename from packages/google_maps_flutter/google_maps_flutter_platform_interface/AUTHORS rename to packages/quick_actions/quick_actions_platform_interface/AUTHORS index 493a0b4ef9c2..0ca697b6a756 100644 --- a/packages/google_maps_flutter/google_maps_flutter_platform_interface/AUTHORS +++ b/packages/quick_actions/quick_actions_platform_interface/AUTHORS @@ -64,3 +64,4 @@ Aleksandr Yurkovskiy Anton Borries Alex Li Rahul Raj <64.rahulraj@gmail.com> +Daniel Roek diff --git a/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/sensors/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/share/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java b/packages/share/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/share/example/android/app/src/main/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/share/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/share/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/share/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerUITests/Info.plist b/packages/share/example/ios/RunnerTests/Info.plist similarity index 100% rename from packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerUITests/Info.plist rename to packages/share/example/ios/RunnerTests/Info.plist diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/GoogleMapsTests.m b/packages/share/example/ios/RunnerTests/RunnerTests.m similarity index 59% rename from packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/GoogleMapsTests.m rename to packages/share/example/ios/RunnerTests/RunnerTests.m index 5249145f0c87..3c4c341fd451 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/RunnerTests/GoogleMapsTests.m +++ b/packages/share/example/ios/RunnerTests/RunnerTests.m @@ -2,16 +2,16 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -@import google_maps_flutter; +@import share; @import XCTest; -@interface GoogleMapsTests : XCTestCase +@interface ShareTests : XCTestCase @end -@implementation GoogleMapsTests +@implementation ShareTests - (void)testPlugin { - FLTGoogleMapsPlugin* plugin = [[FLTGoogleMapsPlugin alloc] init]; + FLTSharePlugin* plugin = [[FLTSharePlugin alloc] init]; XCTAssertNotNil(plugin); } 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/test/java/io/flutter/plugins/sharedpreferences/SharedPreferencesTest.java b/packages/shared_preferences/shared_preferences/android/src/test/java/io/flutter/plugins/sharedpreferences/SharedPreferencesTest.java new file mode 100644 index 000000000000..13d0ff8b40c1 --- /dev/null +++ b/packages/shared_preferences/shared_preferences/android/src/test/java/io/flutter/plugins/sharedpreferences/SharedPreferencesTest.java @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.sharedpreferences; + +import org.junit.Test; + +public class SharedPreferencesTest { + // This is only a placeholder test and doesn't actually initialize the plugin. + @Test + public void initPluginDoesNotThrow() { + final SharedPreferencesPlugin plugin = new SharedPreferencesPlugin(); + } +} diff --git a/packages/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/example/integration_test/shared_preferences_web_test.dart b/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart new file mode 100644 index 000000000000..d3bfa49af8a0 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/integration_test/shared_preferences_web_test.dart @@ -0,0 +1,93 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart: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'; + +const Map kTestValues = { + 'flutter.String': 'hello world', + 'flutter.Bool': true, + 'flutter.Int': 42, + 'flutter.Double': 3.14159, + 'flutter.StringList': ['foo', 'bar'], +}; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('SharedPreferencesPlugin', () { + setUp(() { + html.window.localStorage.clear(); + }); + + testWidgets('registers itself', (WidgetTester tester) async { + SharedPreferencesStorePlatform.instance = + MethodChannelSharedPreferencesStore(); + expect(SharedPreferencesStorePlatform.instance, + isNot(isA())); + SharedPreferencesPlugin.registerWith(null); + expect(SharedPreferencesStorePlatform.instance, + isA()); + }); + + testWidgets('getAll', (WidgetTester tester) async { + final SharedPreferencesPlugin store = SharedPreferencesPlugin(); + expect(await store.getAll(), isEmpty); + + html.window.localStorage['flutter.testKey'] = '"test value"'; + html.window.localStorage['unprefixed_key'] = 'not a flutter value'; + final Map allData = await store.getAll(); + expect(allData, hasLength(1)); + expect(allData['flutter.testKey'], 'test value'); + }); + + testWidgets('remove', (WidgetTester tester) async { + final SharedPreferencesPlugin store = SharedPreferencesPlugin(); + html.window.localStorage['flutter.testKey'] = '"test value"'; + expect(html.window.localStorage['flutter.testKey'], isNotNull); + expect(await store.remove('flutter.testKey'), isTrue); + expect(html.window.localStorage['flutter.testKey'], isNull); + expect( + () => store.remove('unprefixed'), + throwsA(isA()), + ); + }); + + testWidgets('setValue', (WidgetTester tester) async { + final SharedPreferencesPlugin store = SharedPreferencesPlugin(); + for (final String key in kTestValues.keys) { + final dynamic value = kTestValues[key]; + expect(await store.setValue(key.split('.').last, key, value), true); + } + expect(html.window.localStorage.keys, hasLength(kTestValues.length)); + for (final String key in html.window.localStorage.keys) { + expect(html.window.localStorage[key], json.encode(kTestValues[key])); + } + + // Check that generics are preserved. + expect((await store.getAll())['flutter.StringList'], isA>()); + + // Invalid key format. + expect( + () => store.setValue('String', 'unprefixed', 'hello'), + throwsA(isA()), + ); + }); + + testWidgets('clear', (WidgetTester tester) async { + final SharedPreferencesPlugin store = SharedPreferencesPlugin(); + html.window.localStorage['flutter.testKey1'] = '"test value"'; + html.window.localStorage['flutter.testKey2'] = '42'; + html.window.localStorage['unprefixed_key'] = 'not a flutter value'; + expect(await store.clear(), isTrue); + expect(html.window.localStorage.keys.single, 'unprefixed_key'); + }); + }); +} diff --git a/packages/shared_preferences/shared_preferences_web/example/lib/main.dart b/packages/shared_preferences/shared_preferences_web/example/lib/main.dart new file mode 100644 index 000000000000..341913a18490 --- /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 const Directionality( + textDirection: TextDirection.ltr, + child: Text('Testing... Look at the console output for results!'), + ); + } +} diff --git a/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml b/packages/shared_preferences/shared_preferences_web/example/pubspec.yaml new file mode 100644 index 000000000000..832ba912e5a8 --- /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: + flutter: + sdk: flutter + shared_preferences_web: + path: ../ + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + js: ^0.6.3 diff --git a/packages/shared_preferences/shared_preferences_web/example/run_test.sh b/packages/shared_preferences/shared_preferences_web/example/run_test.sh new file mode 100755 index 000000000000..aa52974f310e --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/run_test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/bash +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +if pgrep -lf chromedriver > /dev/null; then + echo "chromedriver is running." + + if [ $# -eq 0 ]; then + echo "No target specified, running all tests..." + find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' + else + echo "Running test target: $1..." + set -x + flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target=$1 + fi + + else + echo "chromedriver is not running." + echo "Please, check the README.md for instructions on how to use run_test.sh" +fi + diff --git a/packages/shared_preferences/shared_preferences_web/example/test_driver/integration_test.dart b/packages/shared_preferences/shared_preferences_web/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/shared_preferences/shared_preferences_web/example/web/index.html b/packages/shared_preferences/shared_preferences_web/example/web/index.html new file mode 100644 index 000000000000..7fb138cc90fa --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/example/web/index.html @@ -0,0 +1,13 @@ + + + + + + example + + + + + diff --git a/packages/shared_preferences/shared_preferences_web/test/README.md b/packages/shared_preferences/shared_preferences_web/test/README.md new file mode 100644 index 000000000000..7c5b4ad682ba --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/test/README.md @@ -0,0 +1,5 @@ +## test + +This package uses integration tests for testing. + +See `example/README.md` for more info. diff --git a/packages/shared_preferences/shared_preferences_web/test/tests_exist_elsewhere_test.dart b/packages/shared_preferences/shared_preferences_web/test/tests_exist_elsewhere_test.dart new file mode 100644 index 000000000000..442c50144727 --- /dev/null +++ b/packages/shared_preferences/shared_preferences_web/test/tests_exist_elsewhere_test.dart @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Tell the user where to find the real tests', () { + print('---'); + print('This package uses integration_test for its tests.'); + print('See `example/README.md` for more info.'); + print('---'); + }); +} diff --git a/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_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_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_windows/windows/include/url_launcher_windows/url_launcher_windows.h b/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h new file mode 100644 index 000000000000..251471c9fe56 --- /dev/null +++ b/packages/url_launcher/url_launcher_windows/windows/include/url_launcher_windows/url_launcher_windows.h @@ -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. +#ifndef PACKAGES_URL_LAUNCHER_URL_LAUNCHER_WINDOWS_WINDOWS_INCLUDE_URL_LAUNCHER_WINDOWS_URL_LAUNCHER_PLUGIN_H_ +#define PACKAGES_URL_LAUNCHER_URL_LAUNCHER_WINDOWS_WINDOWS_INCLUDE_URL_LAUNCHER_WINDOWS_URL_LAUNCHER_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void UrlLauncherWindowsRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // PACKAGES_URL_LAUNCHER_URL_LAUNCHER_WINDOWS_WINDOWS_INCLUDE_URL_LAUNCHER_WINDOWS_URL_LAUNCHER_PLUGIN_H_ 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.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/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java b/packages/video_player/video_player/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java new file mode 100644 index 000000000000..ec960b7a4480 --- /dev/null +++ b/packages/video_player/video_player/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import org.junit.Test; + +public class VideoPlayerTest { + // This is only a placeholder test and doesn't actually initialize the plugin. + @Test + public void initPluginDoesNotThrow() { + final VideoPlayerPlugin plugin = new VideoPlayerPlugin(); + } +} diff --git a/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt b/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt new file mode 100644 index 000000000000..1dca2c58695e --- /dev/null +++ b/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00:00.200 --> 00:00:01.750 +[ Birds chirping ] + +00:00:02.300 --> 00:00:05.000 +[ Buzzing ] diff --git a/packages/video_player/video_player/example/integration_test/controller_swap_test.dart b/packages/video_player/video_player/example/integration_test/controller_swap_test.dart new file mode 100644 index 000000000000..cae51767f4aa --- /dev/null +++ b/packages/video_player/video_player/example/integration_test/controller_swap_test.dart @@ -0,0 +1,92 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/video_player.dart'; + +const Duration _playDuration = Duration(seconds: 1); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets( + 'can substitute one controller by another without crashing', + (WidgetTester tester) async { + VideoPlayerController controller = VideoPlayerController.network( + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + ); + VideoPlayerController another = VideoPlayerController.network( + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4', + ); + await controller.initialize(); + await another.initialize(); + await controller.setVolume(0); + await another.setVolume(0); + + final Completer started = Completer(); + final Completer ended = Completer(); + bool startedBuffering = false; + bool endedBuffering = false; + + another.addListener(() { + if (another.value.isBuffering && !startedBuffering) { + startedBuffering = true; + started.complete(); + } + if (startedBuffering && !another.value.isBuffering && !endedBuffering) { + endedBuffering = true; + ended.complete(); + } + }); + + // Inject a widget with `controller`... + await tester.pumpWidget(renderVideoWidget(controller)); + await controller.play(); + await tester.pumpAndSettle(_playDuration); + await controller.pause(); + + // Disposing controller causes the Widget to crash in the next line + // (Issue https://github.com/flutter/flutter/issues/90046) + await controller.dispose(); + + // Now replace it with `another` controller... + await tester.pumpWidget(renderVideoWidget(another)); + await another.play(); + await another.seekTo(const Duration(seconds: 5)); + await tester.pumpAndSettle(_playDuration); + await another.pause(); + + // Expect that `another` played. + expect(another.value.position, + (Duration position) => position > const Duration(seconds: 0)); + + await started; + expect(startedBuffering, true); + + await ended; + expect(endedBuffering, true); + }, + skip: !(kIsWeb || defaultTargetPlatform == TargetPlatform.android), + ); +} + +Widget renderVideoWidget(VideoPlayerController controller) { + return Material( + elevation: 0, + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: AspectRatio( + key: Key('same'), + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ), + ), + ); +} diff --git a/packages/video_player/video_player/lib/src/web_vtt.dart b/packages/video_player/video_player/lib/src/web_vtt.dart new file mode 100644 index 000000000000..6c4527d34d67 --- /dev/null +++ b/packages/video_player/video_player/lib/src/web_vtt.dart @@ -0,0 +1,211 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:html/dom.dart'; + +import 'closed_caption_file.dart'; +import 'package:html/parser.dart' as html_parser; + +/// Represents a [ClosedCaptionFile], parsed from the WebVTT file format. +/// See: https://en.wikipedia.org/wiki/WebVTT +class WebVTTCaptionFile extends ClosedCaptionFile { + /// Parses a string into a [ClosedCaptionFile], assuming [fileContents] is in + /// the WebVTT file format. + /// * See: https://en.wikipedia.org/wiki/WebVTT + WebVTTCaptionFile(String fileContents) + : _captions = _parseCaptionsFromWebVTTString(fileContents); + + @override + List get captions => _captions; + + final List _captions; +} + +List _parseCaptionsFromWebVTTString(String file) { + final List captions = []; + + // Ignore metadata + Set metadata = {'HEADER', 'NOTE', 'REGION', 'WEBVTT'}; + + int captionNumber = 1; + for (List captionLines in _readWebVTTFile(file)) { + // CaptionLines represent a complete caption. + // E.g + // [ + // [00:00.000 --> 01:24.000 align:center] + // ['Introduction'] + // ] + // If caption has just header or time, but no text, `captionLines.length` will be 1. + if (captionLines.length < 2) continue; + + // If caption has header equal metadata, ignore. + String metadaType = captionLines[0].split(' ')[0]; + if (metadata.contains(metadaType)) continue; + + // Caption has header + bool hasHeader = captionLines.length > 2; + if (hasHeader) { + final int? tryParseCaptionNumber = int.tryParse(captionLines[0]); + if (tryParseCaptionNumber != null) { + captionNumber = tryParseCaptionNumber; + } + } + + final _CaptionRange? captionRange = _CaptionRange.fromWebVTTString( + hasHeader ? captionLines[1] : captionLines[0], + ); + + if (captionRange == null) { + continue; + } + + final String text = captionLines.sublist(hasHeader ? 2 : 1).join('\n'); + + // TODO(cyanglaz): Handle special syntax in VTT captions. + // https://github.com/flutter/flutter/issues/90007. + final String textWithoutFormat = _extractTextFromHtml(text); + + final Caption newCaption = Caption( + number: captionNumber, + start: captionRange.start, + end: captionRange.end, + text: textWithoutFormat, + ); + captions.add(newCaption); + captionNumber++; + } + + return captions; +} + +class _CaptionRange { + final Duration start; + final Duration end; + + _CaptionRange(this.start, this.end); + + // Assumes format from an VTT file. + // For example: + // 00:09.000 --> 00:11.000 + static _CaptionRange? fromWebVTTString(String line) { + final RegExp format = + RegExp(_webVTTTimeStamp + _webVTTArrow + _webVTTTimeStamp); + + if (!format.hasMatch(line)) { + return null; + } + + final List times = line.split(_webVTTArrow); + + final Duration? start = _parseWebVTTTimestamp(times[0]); + final Duration? end = _parseWebVTTTimestamp(times[1]); + + if (start == null || end == null) { + return null; + } + + return _CaptionRange(start, end); + } +} + +String _extractTextFromHtml(String htmlString) { + final Document document = html_parser.parse(htmlString); + final Element? body = document.body; + if (body == null) { + return ''; + } + final Element? bodyElement = html_parser.parse(body.text).documentElement; + return bodyElement?.text ?? ''; +} + +// Parses a time stamp in an VTT file into a Duration. +// +// Returns `null` if `timestampString` is in an invalid format. +// +// For example: +// +// _parseWebVTTTimestamp('00:01:08.430') +// returns +// Duration(hours: 0, minutes: 1, seconds: 8, milliseconds: 430) +Duration? _parseWebVTTTimestamp(String timestampString) { + if (!RegExp(_webVTTTimeStamp).hasMatch(timestampString)) { + return null; + } + + final List dotSections = timestampString.split('.'); + final List timeComponents = dotSections[0].split(':'); + + // Validating and parsing the `timestampString`, invalid format will result this method + // to return `null`. See https://www.w3.org/TR/webvtt1/#webvtt-timestamp for valid + // WebVTT timestamp format. + if (timeComponents.length > 3 || timeComponents.length < 2) { + return null; + } + int hours = 0; + if (timeComponents.length == 3) { + final String hourString = timeComponents.removeAt(0); + if (hourString.length < 2) { + return null; + } + hours = int.parse(hourString); + } + final int minutes = int.parse(timeComponents.removeAt(0)); + if (minutes < 0 || minutes > 59) { + return null; + } + final int seconds = int.parse(timeComponents.removeAt(0)); + if (seconds < 0 || seconds > 59) { + return null; + } + + List milisecondsStyles = dotSections[1].split(" "); + + // TODO(cyanglaz): Handle caption styles. + // https://github.com/flutter/flutter/issues/90009. + // ```dart + // if (milisecondsStyles.length > 1) { + // List styles = milisecondsStyles.sublist(1); + // } + // ``` + // For a better readable code style, style parsing should happen before + // calling this method. See: https://github.com/flutter/plugins/pull/2878/files#r713381134. + int milliseconds = int.parse(milisecondsStyles[0]); + + return Duration( + hours: hours, + minutes: minutes, + seconds: seconds, + milliseconds: milliseconds, + ); +} + +// Reads on VTT file and splits it into Lists of strings where each list is one +// caption. +List> _readWebVTTFile(String file) { + final List lines = LineSplitter.split(file).toList(); + + final List> captionStrings = >[]; + List currentCaption = []; + int lineIndex = 0; + for (final String line in lines) { + final bool isLineBlank = line.trim().isEmpty; + if (!isLineBlank) { + currentCaption.add(line); + } + + if (isLineBlank || lineIndex == lines.length - 1) { + captionStrings.add(currentCaption); + currentCaption = []; + } + + lineIndex += 1; + } + + return captionStrings; +} + +const String _webVTTTimeStamp = r'(\d+):(\d{2})(:\d{2})?\.(\d{3})'; +const String _webVTTArrow = r' --> '; diff --git a/packages/video_player/video_player/test/web_vtt_test.dart b/packages/video_player/video_player/test/web_vtt_test.dart new file mode 100644 index 000000000000..59fce98c5b71 --- /dev/null +++ b/packages/video_player/video_player/test/web_vtt_test.dart @@ -0,0 +1,261 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/src/closed_caption_file.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + group('Parse VTT file', () { + WebVTTCaptionFile parsedFile; + + test('with Metadata', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_metadata); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].start, Duration(seconds: 1)); + expect( + parsedFile.captions[0].end, Duration(seconds: 2, milliseconds: 500)); + expect(parsedFile.captions[0].text, 'We are in New York City'); + }); + + test('with Multiline', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_multiline); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].start, + Duration(seconds: 2, milliseconds: 800)); + expect( + parsedFile.captions[0].end, Duration(seconds: 3, milliseconds: 283)); + expect(parsedFile.captions[0].text, + "— It will perforate your stomach.\n— You could die."); + }); + + test('with styles tags', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_styles); + expect(parsedFile.captions.length, 3); + + expect(parsedFile.captions[0].start, + Duration(seconds: 5, milliseconds: 200)); + expect( + parsedFile.captions[0].end, Duration(seconds: 6, milliseconds: 000)); + expect(parsedFile.captions[0].text, + "You know I'm so excited my glasses are falling off here."); + }); + + test('with subtitling features', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_subtitling_features); + expect(parsedFile.captions.length, 3); + + expect(parsedFile.captions[0].number, 1); + expect(parsedFile.captions.last.start, Duration(seconds: 4)); + expect(parsedFile.captions.last.end, Duration(seconds: 5)); + expect(parsedFile.captions.last.text, "Transcrit par Célestes™"); + }); + + test('with [hours]:[minutes]:[seconds].[milliseconds].', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_with_hours); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].number, 1); + expect(parsedFile.captions.last.start, Duration(seconds: 1)); + expect(parsedFile.captions.last.end, Duration(seconds: 2)); + expect(parsedFile.captions.last.text, "This is a test."); + }); + + test('with [minutes]:[seconds].[milliseconds].', () { + parsedFile = WebVTTCaptionFile(_valid_vtt_without_hours); + expect(parsedFile.captions.length, 1); + + expect(parsedFile.captions[0].number, 1); + expect(parsedFile.captions.last.start, Duration(seconds: 3)); + expect(parsedFile.captions.last.end, Duration(seconds: 4)); + expect(parsedFile.captions.last.text, "This is a test."); + }); + + test('with invalid seconds format returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_invalid_seconds); + expect(parsedFile.captions, isEmpty); + }); + + test('with invalid minutes format returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_invalid_minutes); + expect(parsedFile.captions, isEmpty); + }); + + test('with invalid hours format returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_invalid_hours); + expect(parsedFile.captions, isEmpty); + }); + + test('with invalid component length returns empty captions.', () { + parsedFile = WebVTTCaptionFile(_time_component_too_long); + expect(parsedFile.captions, isEmpty); + + parsedFile = WebVTTCaptionFile(_time_component_too_short); + expect(parsedFile.captions, isEmpty); + }); + }); + + test('Parses VTT file with malformed input.', () { + final ClosedCaptionFile parsedFile = WebVTTCaptionFile(_malformedVTT); + + expect(parsedFile.captions.length, 1); + + final Caption firstCaption = parsedFile.captions.single; + expect(firstCaption.number, 1); + expect(firstCaption.start, Duration(seconds: 13)); + expect(firstCaption.end, Duration(seconds: 16, milliseconds: 0)); + expect(firstCaption.text, 'Valid'); + }); +} + +/// See https://www.w3.org/TR/webvtt1/#introduction-comments +const String _valid_vtt_with_metadata = ''' +WEBVTT Kind: captions; Language: en + +REGION +id:bill +width:40% +lines:3 +regionanchor:100%,100% +viewportanchor:90%,90% +scroll:up + +NOTE +This file was written by Jill. I hope +you enjoy reading it. Some things to +bear in mind: +- I was lip-reading, so the cues may +not be 100% accurate +- I didn’t pay too close attention to +when the cues should start or end. + +1 +00:01.000 --> 00:02.500 +We are in New York City +'''; + +/// See https://www.w3.org/TR/webvtt1/#introduction-multiple-lines +const String _valid_vtt_with_multiline = ''' +WEBVTT + +2 +00:02.800 --> 00:03.283 +— It will perforate your stomach. +— You could die. + +'''; + +/// See https://www.w3.org/TR/webvtt1/#styling +const String _valid_vtt_with_styles = ''' +WEBVTT + +00:05.200 --> 00:06.000 align:start size:50% +You know I'm so excited my glasses are falling off here. + +00:00:06.050 --> 00:00:06.150 +I have a different time! + +00:06.200 --> 00:06.900 +This is yellow text on a blue background + +'''; + +//See https://www.w3.org/TR/webvtt1/#introduction-other-features +const String _valid_vtt_with_subtitling_features = ''' +WEBVTT + +test +00:00.000 --> 00:02.000 +This is a test. + +Slide 1 +00:00:00.000 --> 00:00:10.700 +Title Slide + +crédit de transcription +00:04.000 --> 00:05.000 +Transcrit par Célestes™ + +'''; + +/// With format [hours]:[minutes]:[seconds].[milliseconds] +const String _valid_vtt_with_hours = ''' +WEBVTT + +test +00:00:01.000 --> 00:00:02.000 +This is a test. + +'''; + +/// Invalid seconds format. +const String _invalid_seconds = ''' +WEBVTT + +60:00:000.000 --> 60:02:000.000 +This is a test. + +'''; + +/// Invalid minutes format. +const String _invalid_minutes = ''' +WEBVTT + +60:60:00.000 --> 60:70:00.000 +This is a test. + +'''; + +/// Invalid hours format. +const String _invalid_hours = ''' +WEBVTT + +5:00:00.000 --> 5:02:00.000 +This is a test. + +'''; + +/// Invalid seconds format. +const String _time_component_too_long = ''' +WEBVTT + +60:00:00:00.000 --> 60:02:00:00.000 +This is a test. + +'''; + +/// Invalid seconds format. +const String _time_component_too_short = ''' +WEBVTT + +60:00.000 --> 60:02.000 +This is a test. + +'''; + +/// With format [minutes]:[seconds].[milliseconds] +const String _valid_vtt_without_hours = ''' +WEBVTT + +00:03.000 --> 00:04.000 +This is a test. + +'''; + +const String _malformedVTT = ''' + +WEBVTT Kind: captions; Language: en + +00:09.000--> 00:11.430 +This one should be ignored because the arrow needs a space. + +00:13.000 --> 00:16.000 +Valid + +00:16.000 --> 00:8.000 +This one should be ignored because the time is missing a digit. + +'''; diff --git a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java index ebc7c31987f4..e7dff60dd51e 100644 --- a/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java +++ b/packages/webview_flutter/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java @@ -193,6 +193,12 @@ public void onMethodCall(MethodCall methodCall, Result result) { case "loadUrl": loadUrl(methodCall, result); break; + case "loadAssetHtmlFile": + loadAssetHtmlFile(methodCall, result); + break; + case "loadLocalHtmlFile": + loadLocalHtmlFile(methodCall, result); + break; case "updateSettings": updateSettings(methodCall, result); break; @@ -257,6 +263,18 @@ private void loadUrl(MethodCall methodCall, Result result) { webView.loadUrl(url, headers); result.success(null); } + + private void loadAssetHtmlFile(MethodCall methodCall, Result result) { + String url = (String) methodCall.arguments; + webView.loadUrl("file:///android_asset/flutter_assets/" + url); + result.success(null); + } + + private void loadLocalHtmlFile(MethodCall methodCall, Result result) { + String url = (String) methodCall.arguments; + webView.loadUrl("file:///" + url); + result.success(null); + } private void canGoBack(Result result) { result.success(webView.canGoBack()); diff --git a/packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m b/packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m index 9f01416acc6a..76ab290a2fbe 100644 --- a/packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m +++ b/packages/webview_flutter/ios/Classes/FLTWebViewFlutterPlugin.m @@ -9,8 +9,7 @@ @implementation FLTWebViewFlutterPlugin + (void)registerWithRegistrar:(NSObject*)registrar { - FLTWebViewFactory* webviewFactory = - [[FLTWebViewFactory alloc] initWithMessenger:registrar.messenger]; + FLTWebViewFactory* webviewFactory = [[FLTWebViewFactory alloc] initWithRegistrar:registrar]; [registrar registerViewFactory:webviewFactory withId:@"plugins.flutter.io/webview"]; [FLTCookieManager registerWithRegistrar:registrar]; } diff --git a/packages/webview_flutter/ios/Classes/FlutterWebView.h b/packages/webview_flutter/ios/Classes/FlutterWebView.h index 6e795f7d1528..9b8a8c9f0505 100644 --- a/packages/webview_flutter/ios/Classes/FlutterWebView.h +++ b/packages/webview_flutter/ios/Classes/FlutterWebView.h @@ -12,13 +12,13 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args - binaryMessenger:(NSObject*)messenger; + registrar:(NSObject*)registrar; - (UIView*)view; @end @interface FLTWebViewFactory : NSObject -- (instancetype)initWithMessenger:(NSObject*)messenger; +- (instancetype)initWithRegistrar:(NSObject*)registrar; @end /** diff --git a/packages/webview_flutter/ios/Classes/FlutterWebView.m b/packages/webview_flutter/ios/Classes/FlutterWebView.m index b6f0b268ab02..833e9441129f 100644 --- a/packages/webview_flutter/ios/Classes/FlutterWebView.m +++ b/packages/webview_flutter/ios/Classes/FlutterWebView.m @@ -9,12 +9,14 @@ @implementation FLTWebViewFactory { NSObject* _messenger; + NSObject* _registrar; } -- (instancetype)initWithMessenger:(NSObject*)messenger { +- (instancetype)initWithRegistrar:(NSObject*)registrar { self = [super init]; if (self) { - _messenger = messenger; + _registrar = registrar; + _messenger = registrar.messenger; } return self; } @@ -29,7 +31,7 @@ - (instancetype)initWithMessenger:(NSObject*)messenger { FLTWebViewController* webviewController = [[FLTWebViewController alloc] initWithFrame:frame viewIdentifier:viewId arguments:args - binaryMessenger:_messenger]; + registrar:_registrar]; return webviewController; } @@ -65,18 +67,21 @@ @implementation FLTWebViewController { // The set of registered JavaScript channel names. NSMutableSet* _javaScriptChannelNames; FLTWKNavigationDelegate* _navigationDelegate; + NSObject* _registrar; FLTWKProgressionDelegate* _progressionDelegate; } - (instancetype)initWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args - binaryMessenger:(NSObject*)messenger { + registrar:(nonnull NSObject*)registrar { if (self = [super init]) { _viewId = viewId; + _registrar = registrar; NSString* channelName = [NSString stringWithFormat:@"plugins.flutter.io/webview_%lld", viewId]; - _channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger]; + _channel = [FlutterMethodChannel methodChannelWithName:channelName + binaryMessenger:_registrar.messenger]; _javaScriptChannelNames = [[NSMutableSet alloc] init]; WKUserContentController* userContentController = [[WKUserContentController alloc] init]; @@ -137,6 +142,10 @@ - (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { [self onUpdateSettings:call result:result]; } else if ([[call method] isEqualToString:@"loadUrl"]) { [self onLoadUrl:call result:result]; + } else if ([[call method] isEqualToString:@"loadAssetHtmlFile"]) { + [self onLoadAssetHtmlFile:call result:result]; + } else if ([[call method] isEqualToString:@"loadLocalHtmlFile"]) { + [self onLoadLocalHtmlFile:call result:result]; } else if ([[call method] isEqualToString:@"canGoBack"]) { [self onCanGoBack:call result:result]; } else if ([[call method] isEqualToString:@"canGoForward"]) { @@ -192,6 +201,29 @@ - (void)onLoadUrl:(FlutterMethodCall*)call result:(FlutterResult)result { } } +- (void)onLoadAssetHtmlFile:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* url = [call arguments]; + if (![self loadAssetHtmlFile:url]) { + result([FlutterError errorWithCode:@"loadAssetHtmlFile_failed" + message:@"Failed parsing the URL" + details:[NSString stringWithFormat:@"URL was: '%@'", url]]); + } else { + result(nil); + } +} + +- (void)onLoadLocalHtmlFile:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* url = [call arguments]; + if (![self loadLocalHtmlFile:url]) { + result([FlutterError errorWithCode:@"loadAssetHtmlFile_failed" + message:@"Failed parsing the URL" + details:[NSString stringWithFormat:@"URL was: '%@'", url]]); + } else { + result(nil); + } +} + + - (void)onCanGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { BOOL canGoBack = [_webView canGoBack]; result(@(canGoBack)); @@ -440,6 +472,58 @@ - (bool)loadUrl:(NSString*)url withHeaders:(NSDictionary*) return true; } +- (bool)loadAssetHtmlFile:(NSString*)url { + NSArray* array = [url componentsSeparatedByString:@"?"]; + NSString* pathString = [array objectAtIndex:0]; + NSLog(@"%@%@", @"pathString: ", pathString); + NSString* key = [_registrar lookupKeyForAsset:pathString]; + NSURL* baseURL = [[NSBundle mainBundle] URLForResource:key withExtension:nil]; + if (!baseURL) { + return false; + } + NSURL* newUrl = baseURL; + if ([array count] > 1) { + NSString* queryString = [array objectAtIndex:1]; + NSLog(@"%@%@", @"queryString: ", queryString); + NSString* queryPart = [NSString stringWithFormat:@"%@%@", @"?", queryString]; + NSLog(@"%@%@", @"queryPart: ", queryPart); + newUrl = [NSURL URLWithString:queryPart relativeToURL:baseURL]; + } + if (@available(iOS 9.0, *)) { + [_webView loadFileURL:newUrl allowingReadAccessToURL:[NSURL URLWithString:@"file:///"]]; + } else { + return false; + } + return true; +} + +- (bool)loadLocalHtmlFile:(NSString*)url { + NSArray* array = [url componentsSeparatedByString:@"?"]; + NSString* pathString = [array objectAtIndex:0]; + NSLog(@"%@%@", @"pathString: ", pathString); + NSString* key = [_registrar lookupKeyForAsset:pathString]; + NSURL* baseURL = [[NSBundle mainBundle] URLForResource:key withExtension:nil]; + if (!baseURL) { + [_webView loadFileURL:[NSURL fileURLWithPath:pathString] + allowingReadAccessToURL:[NSURL fileURLWithPath:pathString]]; + return true; + } + NSURL* newUrl = baseURL; + if ([array count] > 1) { + NSString* queryString = [array objectAtIndex:1]; + NSLog(@"%@%@", @"queryString: ", queryString); + NSString* queryPart = [NSString stringWithFormat:@"%@%@", @"?", queryString]; + NSLog(@"%@%@", @"queryPart: ", queryPart); + newUrl = [NSURL URLWithString:queryPart relativeToURL:baseURL]; + } + if (@available(iOS 9.0, *)) { + [_webView loadFileURL:newUrl allowingReadAccessToURL:[NSURL URLWithString:@"file:///"]]; + } else { + return false; + } + return true; +} + - (void)registerJavaScriptChannels:(NSSet*)channelNames controller:(WKUserContentController*)userContentController { for (NSString* channelName in channelNames) { diff --git a/packages/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/lib/platform_interface.dart index 92aa87b7480f..1871b0a7ad2e 100644 --- a/packages/webview_flutter/lib/platform_interface.dart +++ b/packages/webview_flutter/lib/platform_interface.dart @@ -186,6 +186,26 @@ abstract class WebViewPlatformController { throw UnimplementedError( "WebView loadUrl is not implemented on the current platform"); } + + /// Load html file from local path + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadAssetHtmlFile(String url) { + throw UnimplementedError( + "WebView loadAssetHtmlFile is not implemented on the current platform"); + } + + /// Load html file from local path + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadLocalHtmlFile(String url) { + throw UnimplementedError( + "WebView loadLocalHtmlFile is not implemented on the current platform"); + } /// Updates the webview settings. /// diff --git a/packages/webview_flutter/lib/src/webview_method_channel.dart b/packages/webview_flutter/lib/src/webview_method_channel.dart index 05831a9d8794..d6f66838645b 100644 --- a/packages/webview_flutter/lib/src/webview_method_channel.dart +++ b/packages/webview_flutter/lib/src/webview_method_channel.dart @@ -83,6 +83,14 @@ class MethodChannelWebViewPlatform implements WebViewPlatformController { 'headers': headers, }); } + + @override + Future loadAssetHtmlFile(String url) => + _channel.invokeMethod('loadAssetHtmlFile', url); + + @override + Future loadLocalHtmlFile(String url) => + _channel.invokeMethod('loadLocalHtmlFile', url); @override Future currentUrl() => _channel.invokeMethod('currentUrl'); diff --git a/packages/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/lib/webview_flutter.dart index 74d8af8d4687..82d5945dfb1b 100644 --- a/packages/webview_flutter/lib/webview_flutter.dart +++ b/packages/webview_flutter/lib/webview_flutter.dart @@ -648,6 +648,22 @@ class WebViewController { _validateUrlString(url); return _webViewPlatformController.loadUrl(url, headers); } + + /// Load html file from assets + /// + /// `url` must not be null. + Future loadAssetHtmlFile(String url) async { + assert(url != null); + return _webViewPlatformController.loadAssetHtmlFile(url); + } + + /// Load html file from local path + /// + /// `url` must not be null. + Future loadLocalHtmlFile(String url) async { + assert(url != null); + return _webViewPlatformController.loadLocalHtmlFile(url); + } /// Accessor to the current URL that the WebView is displaying. /// diff --git a/packages/webview_flutter/webview_flutter/AUTHORS b/packages/webview_flutter/webview_flutter/AUTHORS new file mode 100644 index 000000000000..85628e432f60 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/AUTHORS @@ -0,0 +1,68 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Nick Bradshaw +Antonino Di Natale diff --git a/packages/webview_flutter/webview_flutter/CHANGELOG.md b/packages/webview_flutter/webview_flutter/CHANGELOG.md new file mode 100644 index 000000000000..8f4826823c38 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/CHANGELOG.md @@ -0,0 +1,486 @@ +## 2.3.0 + +* Add ability to enable/disable zoom functionality. + +## 2.2.0 + +* Added `runJavascript` and `runJavascriptForResult` to supersede `evaluateJavascript`. +* Deprecated `evaluateJavascript`. + +## 2.1.2 + +* Fix typos in the README. + +## 2.1.1 + +* Fixed `_CastError` that was thrown when running the example App. + +## 2.1.0 + +* Migrated to fully federated architecture. + +## 2.0.14 + +* Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0. + +## 2.0.13 + +* Send URL of File to download to the NavigationDelegate on Android just like it is already done on iOS. +* Updated Android lint settings. + +## 2.0.12 + +* 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. +* Suppress deprecation warning for iOS APIs deprecated in iOS 9. + +## 2.0.8 + +* Migrate maven repository from jcenter to mavenCentral. + +## 2.0.7 + +* Republished 2.0.6 with Flutter 2.2 to avoid https://github.com/dart-lang/pub/issues/3001 + +## 2.0.6 + +* WebView requires at least Android 19 if you are using +hybrid composition ([flutter/issues/59894](https://github.com/flutter/flutter/issues/59894)). + +## 2.0.5 + +* Example app observes `uiMode`, so the WebView isn't reattached when the UI mode changes. (e.g. switching to Dark mode). + +## 2.0.4 + +* Fix a bug where `allowsInlineMediaPlayback` is not respected on iOS. + +## 2.0.3 + +* Fixes bug where scroll bars on the Android non-hybrid WebView are rendered on +the wrong side of the screen. + +## 2.0.2 + +* Fixes bug where text fields are hidden behind the keyboard +when hybrid composition is used [flutter/issues/75667](https://github.com/flutter/flutter/issues/75667). + +## 2.0.1 + +* Run CocoaPods iOS tests in RunnerUITests target + +## 2.0.0 + +* Migration to null-safety. +* Added support for progress tracking. +* Add section to the wiki explaining how to use Material components. +* Update integration test to workaround an iOS 14 issue with `evaluateJavascript`. +* Fix `onWebResourceError` on iOS. +* Fix outdated links across a number of markdown files ([#3276](https://github.com/flutter/plugins/pull/3276)) +* Added `allowsInlineMediaPlayback` property. + +## 1.0.8 + +* Update Flutter SDK constraint. + +## 1.0.7 + +* Minor documentation update to indicate known issue on iOS 13.4 and 13.5. + * See: https://github.com/flutter/flutter/issues/53490 + +## 1.0.6 + +* Invoke the WebView.onWebResourceError on iOS when the webview content process crashes. + +## 1.0.5 + +* Fix example in the readme. + +## 1.0.4 + +* Suppress the `deprecated_member_use` warning in the example app for `ScaffoldMessenger.showSnackBar`. + +## 1.0.3 + +* Update android compileSdkVersion to 29. + +## 1.0.2 + +* Android Code Inspection and Clean up. + +## 1.0.1 + +* Add documentation for `WebViewPlatformCreatedCallback`. + +## 1.0.0 - Out of developer preview 🎉. + +* Bumped the minimal Flutter SDK to 1.22 where platform views are out of developer preview, and +performing better on iOS. Flutter 1.22 no longer requires adding the +`io.flutter.embedded_views_preview` flag to `Info.plist`. + +* Added support for Hybrid Composition on Android (see opt-in instructions in [README](https://github.com/flutter/plugins/blob/master/packages/webview_flutter/README.md#android)) + * Lowered the required Android API to 19 (was previously 20): [#23728](https://github.com/flutter/flutter/issues/23728). + * Fixed the following issues: + * 🎹 Keyboard: [#41089](https://github.com/flutter/flutter/issues/41089), [#36478](https://github.com/flutter/flutter/issues/36478), [#51254](https://github.com/flutter/flutter/issues/51254), [#50716](https://github.com/flutter/flutter/issues/50716), [#55724](https://github.com/flutter/flutter/issues/55724), [#56513](https://github.com/flutter/flutter/issues/56513), [#56515](https://github.com/flutter/flutter/issues/56515), [#61085](https://github.com/flutter/flutter/issues/61085), [#62205](https://github.com/flutter/flutter/issues/62205), [#62547](https://github.com/flutter/flutter/issues/62547), [#58943](https://github.com/flutter/flutter/issues/58943), [#56361](https://github.com/flutter/flutter/issues/56361), [#56361](https://github.com/flutter/flutter/issues/42902), [#40716](https://github.com/flutter/flutter/issues/40716), [#37989](https://github.com/flutter/flutter/issues/37989), [#27924](https://github.com/flutter/flutter/issues/27924). + * ♿️ Accessibility: [#50716](https://github.com/flutter/flutter/issues/50716). + * ⚡️ Performance: [#61280](https://github.com/flutter/flutter/issues/61280), [#31243](https://github.com/flutter/flutter/issues/31243), [#52211](https://github.com/flutter/flutter/issues/52211). + * 📹 Video: [#5191](https://github.com/flutter/flutter/issues/5191). + +## 0.3.24 + +* Keep handling deprecated Android v1 classes for backward compatibility. + +## 0.3.23 + +* Handle WebView multi-window support. + +## 0.3.22+2 + +* Update package:e2e reference to use the local version in the flutter/plugins + repository. + +## 0.3.22+1 + +* Update the `setAndGetScrollPosition` to use hard coded values and add a `pumpAndSettle` call. + +## 0.3.22 + +* Add support for passing a failing url. + +## 0.3.21 + +* Enable programmatic scrolling using Android's WebView.scrollTo & iOS WKWebView.scrollView.contentOffset. + +## 0.3.20+2 + +* Fix CocoaPods podspec lint warnings. + +## 0.3.20+1 + +* OCMock module import -> #import, unit tests compile generated as library. +* Fix select drop down crash on old Android tablets (https://github.com/flutter/flutter/issues/54164). + +## 0.3.20 + +* Added support for receiving web resource loading errors. See `WebView.onWebResourceError`. + +## 0.3.19+10 + +* Replace deprecated `getFlutterEngine` call on Android. + +## 0.3.19+9 + +* Remove example app's iOS workspace settings. + +## 0.3.19+8 + +* Make the pedantic dev_dependency explicit. + +## 0.3.19+7 + +* Remove the Flutter SDK constraint upper bound. + +## 0.3.19+6 + +* Enable opening links that target the "_blank" window (links open in same window). + +## 0.3.19+5 + +* On iOS, always keep contentInsets of the WebView to be 0. +* Fix XCTest case to follow XCTest naming convention. + +## 0.3.19+4 + +* On iOS, fix the scroll view content inset is automatically adjusted. After the fix, the content position of the WebView is customizable by Flutter. +* Fix an iOS 13 bug where the scroll indicator shows at random location. + +## 0.3.19+3 + +* Setup XCTests. + +## 0.3.19+2 + +* Migrate from deprecated BinaryMessages to ServicesBinding.instance.defaultBinaryMessenger. + +## 0.3.19+1 + +* Raise min Flutter SDK requirement to the latest stable. v2 embedding apps no + longer need to special case their Flutter SDK requirement like they have + since v0.3.15+3. + +## 0.3.19 + +* Add setting for iOS to allow gesture based navigation. + +## 0.3.18+1 + +* Be explicit that keyboard is not ready for production in README.md. + +## 0.3.18 + +* Add support for onPageStarted event. +* Remove the deprecated `author:` field from pubspec.yaml +* Migrate to the new pubspec platforms manifest. +* Require Flutter SDK 1.10.0 or greater. + +## 0.3.17 + +* Fix pedantic lint errors. Added missing documentation and awaited some futures + in tests and the example app. + +## 0.3.16 + +* Add support for async NavigationDelegates. Synchronous NavigationDelegates + should still continue to function without any change in behavior. + +## 0.3.15+3 + +* Re-land support for the v2 Android embedding. This correctly sets the minimum + SDK to the latest stable and avoid any compile errors. *WARNING:* the V2 + embedding itself still requires the current Flutter master channel + (flutter/flutter@1d4d63a) for text input to work properly on all Android + versions. + +## 0.3.15+2 + +* Remove AndroidX warnings. + +## 0.3.15+1 + +* Revert the prior embedding support add since it requires an API that hasn't + rolled to stable. + +## 0.3.15 + +* Add support for the v2 Android embedding. This shouldn't affect existing + functionality. Plugin authors who use the V2 embedding can now register the + plugin and expect that it correctly responds to app lifecycle changes. + +## 0.3.14+2 + +* Define clang module for iOS. + +## 0.3.14+1 + +* Allow underscores anywhere for Javascript Channel name. + +## 0.3.14 + +* Added a getTitle getter to WebViewController. + +## 0.3.13 + +* Add an optional `userAgent` property to set a custom User Agent. + +## 0.3.12+1 + +* Temporarily revert getTitle (doing this as a patch bump shortly after publishing). + +## 0.3.12 + +* Added a getTitle getter to WebViewController. + +## 0.3.11+6 + +* Calling destroy on Android webview when flutter webview is getting disposed. + +## 0.3.11+5 + +* Reduce compiler warnings regarding iOS9 compatibility by moving a single + method back into a `@available` block. + +## 0.3.11+4 + +* Removed noisy log messages on iOS. + +## 0.3.11+3 + +* Apply the display listeners workaround that was shipped in 0.3.11+1 on + all Android versions prior to P. + +## 0.3.11+2 + +* Add fix for input connection being dropped after a screen resize on certain + Android devices. + +## 0.3.11+1 + +* Work around a bug in old Android WebView versions that was causing a crash + when resizing the webview on old devices. + +## 0.3.11 + +* Add an initialAutoMediaPlaybackPolicy setting for controlling how auto media + playback is restricted. + +## 0.3.10+5 + +* Add dependency on `androidx.annotation:annotation:1.0.0`. + +## 0.3.10+4 + +* Add keyboard text to README. + +## 0.3.10+3 + +* Don't log an unknown setting key error for 'debuggingEnabled' on iOS. + +## 0.3.10+2 + +* Fix InputConnection being lost when combined with route transitions. + +## 0.3.10+1 + +* Add support for simultaenous Flutter `TextInput` and WebView text fields. + +## 0.3.10 + +* Add partial WebView keyboard support for Android versions prior to N. Support + for UIs that also have Flutter `TextInput` fields is still pending. This basic + support currently only works with Flutter `master`. The keyboard will still + appear when it previously did not when run with older versions of Flutter. But + if the WebView is resized while showing the keyboard the text field will need + to be focused multiple times for any input to be registered. + +## 0.3.9+2 + +* Update Dart code to conform to current Dart formatter. + +## 0.3.9+1 + +* Add missing template type parameter to `invokeMethod` calls. +* Bump minimum Flutter version to 1.5.0. +* Replace invokeMethod with invokeMapMethod wherever necessary. + +## 0.3.9 + +* Allow external packages to provide webview implementations for new platforms. + +## 0.3.8+1 + +* Suppress deprecation warning for BinaryMessages. See: https://github.com/flutter/flutter/issues/33446 + +## 0.3.8 + +* Add `debuggingEnabled` property. + +## 0.3.7+1 + +* Fix an issue where JavaScriptChannel messages weren't sent from the platform thread on Android. + +## 0.3.7 + +* Fix loadUrlWithHeaders flaky test. + +## 0.3.6+1 + +* Remove un-used method params in webview\_flutter + +## 0.3.6 + +* Add an optional `headers` field to the controller. + +## 0.3.5+5 + +* Fixed error in documentation of `javascriptChannels`. + +## 0.3.5+4 + +* Fix bugs in the example app by updating it to use a `StatefulWidget`. + +## 0.3.5+3 + +* Make sure to post javascript channel messages from the platform thread. + +## 0.3.5+2 + +* Fix crash from `NavigationDelegate` on later versions of Android. + +## 0.3.5+1 + +* Fix a bug where updates to onPageFinished were ignored. + +## 0.3.5 + +* Added an onPageFinished callback. + +## 0.3.4 + +* Support specifying navigation delegates that can prevent navigations from being executed. + +## 0.3.3+2 + +* Exclude LongPress handler from semantics tree since it does nothing. + +## 0.3.3+1 + +* Fixed a memory leak on Android - the WebView was not properly disposed. + +## 0.3.3 + +* Add clearCache method to WebView controller. + +## 0.3.2+1 + +* Log a more detailed warning at build time about the previous AndroidX + migration. + +## 0.3.2 + +* Added CookieManager to interface with WebView cookies. Currently has the ability to clear cookies. + +## 0.3.1 + +* Added JavaScript channels to facilitate message passing from JavaScript code running inside + the WebView to the Flutter app's Dart code. + +## 0.3.0 + +* **Breaking change**. Migrate from the deprecated original Android Support + Library to AndroidX. This shouldn't result in any functional changes, but it + requires any Android apps using this plugin to [also + migrate](https://developer.android.com/jetpack/androidx/migrate) if they're + using the original support library. + +## 0.2.0 + +* Added a evaluateJavascript method to WebView controller. +* (BREAKING CHANGE) Renamed the `JavaScriptMode` enum to `JavascriptMode`, and the WebView `javasScriptMode` parameter to `javascriptMode`. + +## 0.1.2 + +* Added a reload method to the WebView controller. + +## 0.1.1 + +* Added a `currentUrl` accessor for the WebView controller to look up what URL + is being displayed. + +## 0.1.0+1 + +* Fix null crash when initialUrl is unset on iOS. + +## 0.1.0 + +* Add goBack, goForward, canGoBack, and canGoForward methods to the WebView controller. + +## 0.0.1+1 + +* Fix case for "FLTWebViewFlutterPlugin" (iOS was failing to buld on case-sensitive file systems). + +## 0.0.1 + +* Initial release. diff --git a/packages/webview_flutter/webview_flutter/LICENSE b/packages/webview_flutter/webview_flutter/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/webview_flutter/webview_flutter/README.md b/packages/webview_flutter/webview_flutter/README.md new file mode 100644 index 000000000000..de475ad9acd7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/README.md @@ -0,0 +1,94 @@ +# 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). If you are targeting Android, make sure to read the *Android Platform Views* section below to choose the platform view mode that best suits your needs. + +You can now include a WebView widget in your widget tree. See the +[WebView](https://pub.dev/documentation/webview_flutter/latest/webview_flutter/WebView-class.html) +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. It supports two modes: *Virtual displays* (the current default) and *Hybrid composition*. + +Here are some points to consider when choosing between the two: + +* *Hybrid composition* mode has a built-in keyboard support while *Virtual displays* mode has multiple +[keyboard issues](https://github.com/flutter/flutter/issues?q=is%3Aopen+label%3Avd-only+label%3A%22p%3A+webview-keyboard%22) +* *Hybrid composition* mode requires Android SDK 19+ while *Virtual displays* mode requires Android SDK 20+ +* *Hybrid composition* mode has [performance limitations](https://flutter.dev/docs/development/platform-integration/platform-views#performance) when working on Android versions prior to Android 10 while *Virtual displays* is performant on all supported Android versions + +| | Hybrid composition | Virtual displays | +| --------------------------- | ------------------- | ---------------- | +| **Full keyboard supoport** | yes | no | +| **Android SDK support** | 19+ | 20+ | +| **Full performance** | Android 10+ | always | +| **The default** | no | yes | + +### Using Virtual displays + +The mode is currently enabled by default. You should however make sure to set the correct `minSdkVersion` in `android/app/build.gradle` (if it was previously lower than 20): + +```groovy +android { + defaultConfig { + minSdkVersion 20 + } +} +``` + + +### Using Hybrid Composition + +1. Set the correct `minSdkVersion` in `android/app/build.gradle` (if it was previously lower than 19): + + ```groovy + android { + defaultConfig { + minSdkVersion 19 + } + } + ``` + +2. Set `WebView.platform = SurfaceAndroidWebView();` in `initState()`. + For example: + + ```dart + import 'dart:io'; + + import 'package:webview_flutter/webview_flutter.dart'; + + class WebViewExample extends StatefulWidget { + @override + WebViewExampleState createState() => WebViewExampleState(); + } + + class WebViewExampleState extends State { + @override + void initState() { + super.initState(); + // Enable hybrid composition. + if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView(); + } + + @override + Widget build(BuildContext context) { + return WebView( + initialUrl: 'https://flutter.dev', + ); + } + } + ``` + +### 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/example/.metadata b/packages/webview_flutter/webview_flutter/example/.metadata new file mode 100644 index 000000000000..da83b1ada1bd --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/.metadata @@ -0,0 +1,8 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 1e5cb2d87f8542f9fbbd0f22d528823274be0acb + channel: master diff --git a/packages/webview_flutter/webview_flutter/example/README.md b/packages/webview_flutter/webview_flutter/example/README.md new file mode 100644 index 000000000000..850ee74397a9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/README.md @@ -0,0 +1,8 @@ +# webview_flutter_example + +Demonstrates how to use the webview_flutter plugin. + +## Getting Started + +For help getting started with Flutter, view our online +[documentation](https://flutter.dev/). diff --git a/packages/webview_flutter/webview_flutter/example/android/app/build.gradle b/packages/webview_flutter/webview_flutter/example/android/app/build.gradle new file mode 100644 index 000000000000..9a43699afb2b --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/build.gradle @@ -0,0 +1,62 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "io.flutter.plugins.webviewflutterexample" + minSdkVersion 19 + targetSdkVersion 28 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' +} diff --git a/packages/webview_flutter/webview_flutter/example/android/app/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter/example/android/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..9a4163a4f5ee --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java new file mode 100644 index 000000000000..a32aaebb0ecd --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutterexample; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@DartIntegrationTest +@RunWith(FlutterTestRunner.class) +public class MainActivityTest { + @Rule + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java new file mode 100644 index 000000000000..0b3eeef9b6b7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutterexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.webviewflutter.WebViewFlutterPlugin; +import org.junit.Test; + +public class WebViewTest { + @Test + public void webViewPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(WebViewTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(WebViewFlutterPlugin.class)); + }); + } +} diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/debug/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..28792201bc36 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..b8c8d38d45a5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + 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/android/app/src/main/res/drawable/launch_background.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000000..304732f88420 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000000..db77bb4b7b09 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000000..17987b79bb8a Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000000..09d4391482be Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000000..d5f1c8d34e7a Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000000..4d6372eebdb2 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/values/styles.xml b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..00fa4417cfbe --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/packages/webview_flutter/webview_flutter/example/android/build.gradle b/packages/webview_flutter/webview_flutter/example/android/build.gradle new file mode 100644 index 000000000000..e101ac08df55 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/webview_flutter/webview_flutter/example/android/gradle.properties b/packages/webview_flutter/webview_flutter/example/android/gradle.properties new file mode 100644 index 000000000000..a6738207fd15 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/packages/webview_flutter/webview_flutter/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..2819f022f1fd --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip diff --git a/packages/webview_flutter/webview_flutter/example/android/settings.gradle b/packages/webview_flutter/webview_flutter/example/android/settings.gradle new file mode 100644 index 000000000000..5a2f14fb18f6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/packages/webview_flutter/webview_flutter/example/assets/sample_audio.ogg b/packages/webview_flutter/webview_flutter/example/assets/sample_audio.ogg new file mode 100644 index 000000000000..27e17104277b Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/assets/sample_audio.ogg differ diff --git a/packages/webview_flutter/webview_flutter/example/assets/sample_video.mp4 b/packages/webview_flutter/webview_flutter/example/assets/sample_video.mp4 new file mode 100644 index 000000000000..a203d0cdf13e Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/assets/sample_video.mp4 differ 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 new file mode 100644 index 000000000000..69ddcc685ff8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -0,0 +1,1498 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:webview_flutter/platform_interface.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // URLs to navigate to in tests. These need to be URLs that we are confident will + // always be accessible, and won't do redirection. (E.g., just + // 'https://www.google.com/' will sometimes redirect traffic that looks + // like it's coming from a bot, which is true of these tests). + const String primaryUrl = 'https://flutter.dev/'; + const String secondaryUrl = 'https://www.google.com/robots.txt'; + + const bool _skipDueToIssue86757 = true; + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('initialUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.loadUrl(secondaryUrl); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }, skip: _skipDueToIssue86757); + + testWidgets('evaluateJavascript', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + // ignore: deprecated_member_use + final String result = await controller.evaluateJavascript('1 + 1'); + expect(result, equals('2')); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl with headers', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageStarts = StreamController(); + final StreamController pageLoads = StreamController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarts.add(url); + }, + onPageFinished: (String url) { + pageLoads.add(url); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final Map headers = { + 'test_header': 'flutter_test_header' + }; + await controller.loadUrl('https://flutter-header-echo.herokuapp.com/', + headers: headers); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter-header-echo.herokuapp.com/'); + + await pageStarts.stream.firstWhere((String url) => url == currentUrl); + await pageLoads.stream.firstWhere((String url) => url == currentUrl); + + final String content = await controller + .runJavascriptReturningResult('document.documentElement.innerText'); + expect(content.contains('flutter_test_header'), isTrue); + }, skip: Platform.isAndroid && _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('JavascriptChannel', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final List messagesReceived = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + // This is the data URL for: '' + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'Echo', + onMessageReceived: (JavascriptMessage message) { + messagesReceived.add(message.message); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(messagesReceived, isEmpty); + await controller.runJavascript('Echo.postMessage("hello");'); + expect(messagesReceived, equals(['hello'])); + }, skip: Platform.isAndroid && _skipDueToIssue86757); + + testWidgets('resize webview', (WidgetTester tester) async { + final String resizeTest = ''' + + Resize test + + + + + + '''; + final String resizeTestBase64 = + base64Encode(const Utf8Encoder().convert(resizeTest)); + final Completer resizeCompleter = Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + final GlobalKey key = GlobalKey(); + + final WebView webView = WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$resizeTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptChannels: { + JavascriptChannel( + name: 'Resize', + onMessageReceived: (JavascriptMessage message) { + resizeCompleter.complete(true); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 200, + height: 200, + child: webView, + ), + ], + ), + ), + ); + + await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(resizeCompleter.isCompleted, false); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 400, + height: 400, + child: webView, + ), + ], + ), + ), + ); + + await resizeCompleter.future; + }); + + testWidgets('set custom userAgent', (WidgetTester tester) async { + final Completer controllerCompleter1 = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent1', + onWebViewCreated: (WebViewController controller) { + controllerCompleter1.complete(controller); + }, + ), + ), + ); + final WebViewController controller1 = await controllerCompleter1.future; + final String customUserAgent1 = await _getUserAgent(controller1); + expect(customUserAgent1, 'Custom_User_Agent1'); + // rebuild the WebView with a different user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent2', + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller1); + expect(customUserAgent2, 'Custom_User_Agent2'); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('use default platform userAgent after webView is rebuilt', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + // Build the webView with no user agent to get the default platform user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: primaryUrl, + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String defaultPlatformUserAgent = await _getUserAgent(controller); + // rebuild the WebView with a custom user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ), + ), + ); + final String customUserAgent = await _getUserAgent(controller); + expect(customUserAgent, 'Custom_User_Agent'); + // rebuilds the WebView with no user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller); + expect(customUserAgent2, defaultPlatformUserAgent); + }, skip: Platform.isAndroid && _skipDueToIssue86757); + + group('Video playback policy', () { + late String videoTestBase64; + setUpAll(() async { + final ByteData videoData = + await rootBundle.load('assets/sample_video.mp4'); + final String base64VideoData = + base64Encode(Uint8List.view(videoData.buffer)); + final String videoTest = ''' + + Video auto play + + + + + + + '''; + videoTestBase64 = base64Encode(const Utf8Encoder().convert(videoTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolicy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + + testWidgets('Video plays inline when allowsInlineMediaPlayback is true', + (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: true, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.runJavascriptReturningResult('isFullScreen();'); + 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 { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: false, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.runJavascriptReturningResult('isFullScreen();'); + expect(fullScreen, _webviewBool(true)); + }, skip: Platform.isAndroid); + }); + + group('Audio playback policy', () { + late String audioTestBase64; + setUpAll(() async { + final ByteData audioData = + await rootBundle.load('assets/sample_audio.ogg'); + final String base64AudioData = + base64Encode(Uint8List.view(audioData.buffer)); + final String audioTest = ''' + + Audio auto play + + + + + + + '''; + audioTestBase64 = base64Encode(const Utf8Encoder().convert(audioTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageStarted = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolocy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageStarted = Completer(); + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + }); + + testWidgets('getTitle', (WidgetTester tester) async { + final String getTitleTest = ''' + + Some title + + + + + '''; + final String getTitleTestBase64 = + base64Encode(const Utf8Encoder().convert(getTitleTest)); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$getTitleTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + final String? title = await controller.getTitle(); + expect(title, 'Some title'); + }); + + group('Programmatic Scroll', () { + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
+ + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + // Get the initial position; this ensures that scrollTo is actually + // changing something, but also gives the native view's scroll position + // time to settle. + expect(scrollPosX, isNot(X_SCROLL)); + expect(scrollPosX, isNot(Y_SCROLL)); + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL); + expect(scrollPosY, Y_SCROLL); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL * 2); + expect(scrollPosY, Y_SCROLL * 2); + }, skip: Platform.isAndroid && _skipDueToIssue86757); + }); + + group('SurfaceAndroidWebView', () { + setUpAll(() { + WebView.platform = SurfaceAndroidWebView(); + }); + + tearDownAll(() { + WebView.platform = null; + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
+ + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + expect(X_SCROLL, scrollPosX); + expect(Y_SCROLL, scrollPosY); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(X_SCROLL * 2, scrollPosX); + expect(Y_SCROLL * 2, scrollPosY); + }, skip: !Platform.isAndroid || _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('inputs are scrolled into view when focused', + (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
+ + + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.runAsync(() async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 200, + height: 200, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ), + ); + await Future.delayed(Duration(milliseconds: 20)); + await tester.pump(); + }); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + final String viewportRectJSON = await _runJavascriptReturningResult( + controller, 'JSON.stringify(viewport.getBoundingClientRect())'); + final Map viewportRectRelativeToViewport = + jsonDecode(viewportRectJSON); + + // Check that the input is originally outside of the viewport. + + final String initialInputClientRectJSON = + await _runJavascriptReturningResult( + controller, 'JSON.stringify(inputEl.getBoundingClientRect())'); + final Map initialInputClientRectRelativeToViewport = + jsonDecode(initialInputClientRectJSON); + + expect( + initialInputClientRectRelativeToViewport['bottom'] <= + viewportRectRelativeToViewport['bottom'], + isFalse); + + await controller.runJavascript('inputEl.focus()'); + + // Check that focusing the input brought it into view. + + final String lastInputClientRectJSON = + await _runJavascriptReturningResult( + controller, 'JSON.stringify(inputEl.getBoundingClientRect())'); + final Map lastInputClientRectRelativeToViewport = + jsonDecode(lastInputClientRectJSON); + + expect( + lastInputClientRectRelativeToViewport['top'] >= + viewportRectRelativeToViewport['top'], + isTrue); + expect( + lastInputClientRectRelativeToViewport['bottom'] <= + viewportRectRelativeToViewport['bottom'], + isTrue); + + expect( + lastInputClientRectRelativeToViewport['left'] >= + viewportRectRelativeToViewport['left'], + isTrue); + expect( + lastInputClientRectRelativeToViewport['right'] <= + viewportRectRelativeToViewport['right'], + isTrue); + }, skip: !Platform.isAndroid || _skipDueToIssue86757); + }); + + group('NavigationDelegate', () { + final String blankPage = ""; + final String blankPageEncoded = 'data:text/html;charset=utf-8;base64,' + + base64Encode(const Utf8Encoder().convert(blankPage)); + + testWidgets('can allow requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('location.href = "$secondaryUrl"'); + + await pageLoads.stream.first; // Wait for the next page load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }); + + testWidgets('onWebResourceError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://www.notawebsite..com', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + final WebResourceError error = await errorCompleter.future; + expect(error, isNotNull); + + if (Platform.isIOS) { + expect(error.domain, isNotNull); + expect(error.failingUrl, isNull); + } else if (Platform.isAndroid) { + expect(error.errorType, isNotNull); + expect(error.failingUrl?.startsWith('https://www.notawebsite..com'), + isTrue); + } + }); + + testWidgets('onWebResourceError is not called with valid url', + (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + + testWidgets( + 'onWebResourceError only called for main frame', + (WidgetTester tester) async { + final String iframeTest = ''' + + + + WebResourceError test + + + + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframeTest)); + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,$iframeTestBase64', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }, + ); + + testWidgets('can block requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .runJavascript('location.href = "https://www.youtube.com/"'); + + // There should never be any second page load, since our new URL is + // blocked. Still wait for a potential page change for some time in order + // to give the test a chance to fail. + await pageLoads.stream.first + .timeout(const Duration(milliseconds: 500), onTimeout: () => ''); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, isNot(contains('youtube.com'))); + }); + + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate); + return decision; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('location.href = "$secondaryUrl"'); + + await pageLoads.stream.first; // Wait for second page to load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }); + }); + + testWidgets('launches with gestureNavigationEnabled on iOS', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 300, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + gestureNavigationEnabled: true, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }); + + testWidgets('target _blank opens in same window', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('window.open("$primaryUrl", "_blank")'); + await pageLoaded.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }, + // Flaky on Android: https://github.com/flutter/flutter/issues/86757 + skip: Platform.isAndroid && _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets( + 'can open new window and go back', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(); + }, + initialUrl: primaryUrl, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + expect(controller.currentUrl(), completion(primaryUrl)); + await pageLoaded.future; + pageLoaded = Completer(); + + await controller.runJavascript('window.open("$secondaryUrl")'); + await pageLoaded.future; + pageLoaded = Completer(); + expect(controller.currentUrl(), completion(secondaryUrl)); + + expect(controller.canGoBack(), completion(true)); + await controller.goBack(); + await pageLoaded.future; + expect(controller.currentUrl(), completion(primaryUrl)); + }, + skip: _skipDueToIssue86757, + ); + + testWidgets( + 'JavaScript does not run in parent window', + (WidgetTester tester) async { + final String iframe = ''' + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframe)); + + final String openWindowTest = ''' + + + + XSS test + + + + + + '''; + final String openWindowTestBase64 = + base64Encode(const Utf8Encoder().convert(openWindowTest)); + final Completer controllerCompleter = + Completer(); + final Completer pageLoadCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + initialUrl: + 'data:text/html;charset=utf-8;base64,$openWindowTestBase64', + onPageFinished: (String url) { + pageLoadCompleter.complete(); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoadCompleter.future; + + expect(controller.runJavascriptReturningResult('iframeLoaded'), + completion('true')); + expect( + controller.runJavascriptReturningResult( + 'document.querySelector("p") && document.querySelector("p").textContent'), + completion('null'), + ); + }, + skip: !Platform.isAndroid, + ); +} + +// JavaScript booleans evaluate to different string values on Android and iOS. +// This utility method returns the string boolean value of the current platform. +String _webviewBool(bool value) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return value ? '1' : '0'; + } + return value ? 'true' : 'false'; +} + +/// Returns the value used for the HTTP User-Agent: request header in subsequent HTTP requests. +Future _getUserAgent(WebViewController controller) async { + return _runJavascriptReturningResult(controller, 'navigator.userAgent;'); +} + +Future _runJavascriptReturningResult( + WebViewController controller, String js) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return await controller.runJavascriptReturningResult(js); + } + return jsonDecode(await controller.runJavascriptReturningResult(js)); +} diff --git a/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000000..8d4492f977ad --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Flutter/Debug.xcconfig b/packages/webview_flutter/webview_flutter/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000000..e8efba114687 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/webview_flutter/webview_flutter/example/ios/Flutter/Release.xcconfig b/packages/webview_flutter/webview_flutter/example/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000000..399e9340e6f6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile b/packages/webview_flutter/webview_flutter/example/ios/Podfile similarity index 95% rename from packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile rename to packages/webview_flutter/webview_flutter/example/ios/Podfile index 3924e59aa0f9..66509fcae284 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/ios/Podfile +++ b/packages/webview_flutter/webview_flutter/example/ios/Podfile @@ -29,8 +29,12 @@ flutter_ios_podfile_setup target 'Runner' do flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do inherit! :search_paths + + # Matches test_spec dependency. + pod 'OCMock', '3.5' end end diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..0759b31a2f25 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,727 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */; }; + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 63D2F2FB307F1F037702C198 /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BEC8CD326B252E47ABE6C037 /* libPods-RunnerTests.a */; }; + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; + 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + E6159E2B6496F35B1D4F4096 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C0ABA59F25635F077C9EA161 /* libPods-Runner.a */; }; + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F76266057800028CB91 /* FLTWebViewUITests.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 68BDCAEE23C3F7CB00D9C032 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; + F7151F79266057800028CB91 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 11DF059E983DF25F078B44CC /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3CEFE8F0E91B9792E4EE427B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 4D2B3F45D8E6CA81EA52591E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 5D19D984A61169BB95DB0FED /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWKNavigationDelegateTests.m; sourceTree = ""; }; + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 68BDCAED23C3F7CB00D9C032 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewTests.m; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BEC8CD326B252E47ABE6C037 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + C0ABA59F25635F077C9EA161 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + F7151F74266057800028CB91 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F7151F76266057800028CB91 /* FLTWebViewUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewUITests.m; sourceTree = ""; }; + F7151F78266057800028CB91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 68BDCAE623C3F7CB00D9C032 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 63D2F2FB307F1F037702C198 /* libPods-RunnerTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E6159E2B6496F35B1D4F4096 /* libPods-Runner.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F71266057800028CB91 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 00D2395F7DDFEE571DF3C0B1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C0ABA59F25635F077C9EA161 /* libPods-Runner.a */, + BEC8CD326B252E47ABE6C037 /* libPods-RunnerTests.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */, + 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */, + 68BDCAED23C3F7CB00D9C032 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */, + F7151F75266057800028CB91 /* RunnerUITests */, + 97C146EF1CF9000F007C117D /* Products */, + EA36D6F90B795550E32A139A /* Pods */, + 00D2395F7DDFEE571DF3C0B1 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */, + F7151F74266057800028CB91 /* RunnerUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */, + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 97C146F21CF9000F007C117D /* main.m */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + EA36D6F90B795550E32A139A /* Pods */ = { + isa = PBXGroup; + children = ( + 4D2B3F45D8E6CA81EA52591E /* Pods-Runner.debug.xcconfig */, + 11DF059E983DF25F078B44CC /* Pods-Runner.release.xcconfig */, + 3CEFE8F0E91B9792E4EE427B /* Pods-RunnerTests.debug.xcconfig */, + 5D19D984A61169BB95DB0FED /* Pods-RunnerTests.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + F7151F75266057800028CB91 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + F7151F76266057800028CB91 /* FLTWebViewUITests.m */, + F7151F78266057800028CB91 /* Info.plist */, + ); + path = RunnerUITests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + EA0C9BB56C9A98B4F095051B /* [CP] Check Pods Manifest.lock */, + 68BDCAE523C3F7CB00D9C032 /* Sources */, + 68BDCAE623C3F7CB00D9C032 /* Frameworks */, + 68BDCAE723C3F7CB00D9C032 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 68BDCAEF23C3F7CB00D9C032 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = webview_flutter_exampleTests; + productReference = 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 1B3EA6BF26F6D525A8503093 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; + F7151F73266057800028CB91 /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + F7151F70266057800028CB91 /* Sources */, + F7151F71266057800028CB91 /* Frameworks */, + F7151F72266057800028CB91 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F7151F7A266057800028CB91 /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = F7151F74266057800028CB91 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + DefaultBuildSystemTypeForWorkspace = Original; + LastUpgradeCheck = 1030; + ORGANIZATIONNAME = "The Flutter Authors"; + TargetAttributes = { + 68BDCAE823C3F7CB00D9C032 = { + ProvisioningStyle = Automatic; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + }; + F7151F73266057800028CB91 = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */, + F7151F73266057800028CB91 /* RunnerUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 68BDCAE723C3F7CB00D9C032 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F72266057800028CB91 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1B3EA6BF26F6D525A8503093 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; + EA0C9BB56C9A98B4F095051B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 68BDCAE523C3F7CB00D9C032 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */, + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */, + 97C146F31CF9000F007C117D /* main.m in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F70266057800028CB91 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 68BDCAEF23C3F7CB00D9C032 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 68BDCAEE23C3F7CB00D9C032 /* PBXContainerItemProxy */; + }; + F7151F7A266057800028CB91 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F7151F79266057800028CB91 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 68BDCAF023C3F7CB00D9C032 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3CEFE8F0E91B9792E4EE427B /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + 68BDCAF123C3F7CB00D9C032 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5D19D984A61169BB95DB0FED /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + F7151F7C266057800028CB91 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + F7151F7D266057800028CB91 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 68BDCAF023C3F7CB00D9C032 /* Debug */, + 68BDCAF123C3F7CB00D9C032 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7151F7C266057800028CB91 /* Debug */, + F7151F7D266057800028CB91 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000000..d7453a8ce862 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..21a3cc14c74e --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.h b/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.h new file mode 100644 index 000000000000..0681d288bb70 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.h @@ -0,0 +1,10 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +@interface AppDelegate : FlutterAppDelegate + +@end diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.m b/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.m new file mode 100644 index 000000000000..30b87969f44a --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/AppDelegate.m @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "AppDelegate.h" +#include "GeneratedPluginRegistrant.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [GeneratedPluginRegistrant registerWithRegistry:self]; + // Override point for customization after application launch. + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..d36b1fab2d9d --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 000000000000..3d43d11e66f4 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 000000000000..28c6bf03016f Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000000..f091b6b0bca8 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 000000000000..4cde12118dda Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000000..d0ef06e7edb8 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000000..dcdc2306c285 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000000..c8f9ed8f5cee Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000000..75b2d164a5a9 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 000000000000..c4df70d39da7 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000000..6a84f41e14e2 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000000..d0e1f5853602 Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 000000000000..0bedcf2fd467 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 000000000000..89c2725b70f1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000000..f2e259c7c939 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/Main.storyboard b/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000000..f3c28516fb38 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/Info.plist b/packages/webview_flutter/webview_flutter/example/ios/Runner/Info.plist new file mode 100644 index 000000000000..a810c5a172c0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + webview_flutter_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/Runner/main.m b/packages/webview_flutter/webview_flutter/example/ios/Runner/main.m new file mode 100644 index 000000000000..f97b9ef5c8a1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/Runner/main.m @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m new file mode 100644 index 000000000000..08c2e8b60832 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m @@ -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 Flutter; +@import XCTest; +@import webview_flutter; + +// OCMock library doesn't generate a valid modulemap. +#import + +@interface FLTWKNavigationDelegateTests : XCTestCase + +@property(strong, nonatomic) FlutterMethodChannel *mockMethodChannel; +@property(strong, nonatomic) FLTWKNavigationDelegate *navigationDelegate; + +@end + +@implementation FLTWKNavigationDelegateTests + +- (void)setUp { + self.mockMethodChannel = OCMClassMock(FlutterMethodChannel.class); + self.navigationDelegate = + [[FLTWKNavigationDelegate alloc] initWithChannel:self.mockMethodChannel]; +} + +- (void)testWebViewWebContentProcessDidTerminateCallsRecourseErrorChannel { + WKWebView *webview = OCMClassMock(WKWebView.class); + [self.navigationDelegate webViewWebContentProcessDidTerminate:webview]; + OCMVerify([self.mockMethodChannel invokeMethod:@"onWebResourceError" + arguments:[OCMArg checkWithBlock:^BOOL(NSDictionary *args) { + XCTAssertEqualObjects(args[@"errorType"], + @"webContentProcessTerminated"); + return true; + }]]); +} + +@end diff --git a/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m new file mode 100644 index 000000000000..f8229935cbe6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/FLTWebViewTests.m @@ -0,0 +1,91 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import Flutter; +@import XCTest; +@import webview_flutter; + +// OCMock library doesn't generate a valid modulemap. +#import + +static bool feq(CGFloat a, CGFloat b) { return fabs(b - a) < FLT_EPSILON; } + +@interface FLTWebViewTests : XCTestCase + +@property(strong, nonatomic) NSObject *mockBinaryMessenger; + +@end + +@implementation FLTWebViewTests + +- (void)setUp { + [super setUp]; + self.mockBinaryMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); +} + +- (void)testCanInitFLTWebViewController { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTAssertNotNil(controller); +} + +- (void)testCanInitFLTWebViewFactory { + FLTWebViewFactory *factory = + [[FLTWebViewFactory alloc] initWithMessenger:self.mockBinaryMessenger]; + XCTAssertNotNil(factory); +} + +- (void)webViewContentInsetBehaviorShouldBeNeverOnIOS11 { + if (@available(iOS 11, *)) { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + UIView *view = controller.view; + XCTAssertTrue([view isKindOfClass:WKWebView.class]); + WKWebView *webView = (WKWebView *)view; + XCTAssertEqual(webView.scrollView.contentInsetAdjustmentBehavior, + UIScrollViewContentInsetAdjustmentNever); + } +} + +- (void)testWebViewScrollIndicatorAticautomaticallyAdjustsScrollIndicatorInsetsShouldbeNoOnIOS13 { + if (@available(iOS 13, *)) { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + UIView *view = controller.view; + XCTAssertTrue([view isKindOfClass:WKWebView.class]); + WKWebView *webView = (WKWebView *)view; + XCTAssertFalse(webView.scrollView.automaticallyAdjustsScrollIndicatorInsets); + } +} + +- (void)testContentInsetsSumAlwaysZeroAfterSetFrame { + FLTWKWebView *webView = [[FLTWKWebView alloc] initWithFrame:CGRectMake(0, 0, 300, 400)]; + webView.scrollView.contentInset = UIEdgeInsetsMake(0, 0, 300, 0); + XCTAssertFalse(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + webView.frame = CGRectMake(0, 0, 300, 200); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + XCTAssertTrue(CGRectEqualToRect(webView.frame, CGRectMake(0, 0, 300, 200))); + + if (@available(iOS 11, *)) { + // After iOS 11, we need to make sure the contentInset compensates the adjustedContentInset. + UIScrollView *partialMockScrollView = OCMPartialMock(webView.scrollView); + UIEdgeInsets insetToAdjust = UIEdgeInsetsMake(0, 0, 300, 0); + OCMStub(partialMockScrollView.adjustedContentInset).andReturn(insetToAdjust); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + webView.frame = CGRectMake(0, 0, 300, 100); + XCTAssertTrue(feq(webView.scrollView.contentInset.bottom, -insetToAdjust.bottom)); + XCTAssertTrue(CGRectEqualToRect(webView.frame, CGRectMake(0, 0, 300, 100))); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/Info.plist b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m b/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m new file mode 100644 index 000000000000..d193be745972 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/FLTWebViewUITests.m @@ -0,0 +1,101 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import XCTest; +@import os.log; + +@interface FLTWebViewUITests : XCTestCase +@property(nonatomic, strong) XCUIApplication* app; +@end + +@implementation FLTWebViewUITests + +- (void)setUp { + self.continueAfterFailure = NO; + + self.app = [[XCUIApplication alloc] init]; + [self.app launch]; +} + +- (void)testUserAgent { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* userAgent = app.buttons[@"Show user agent"]; + if (![userAgent waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Show user agent"); + } + NSPredicate* userAgentPredicate = + [NSPredicate predicateWithFormat:@"label BEGINSWITH 'User Agent: Mozilla/5.0 (iPhone; '"]; + XCUIElement* userAgentPopUp = [app.otherElements elementMatchingPredicate:userAgentPredicate]; + XCTAssertFalse(userAgentPopUp.exists); + [userAgent tap]; + if (![userAgentPopUp waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find user agent pop up"); + } +} + +- (void)testCache { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* clearCache = app.buttons[@"Clear cache"]; + if (![clearCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Clear cache"); + } + [clearCache tap]; + + [menu tap]; + + XCUIElement* listCache = app.buttons[@"List cache"]; + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* emptyCachePopup = app.otherElements[@"{\"cacheKeys\":[],\"localStorage\":{}}"]; + if (![emptyCachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find empty cache pop up"); + } + + [menu tap]; + XCUIElement* addCache = app.buttons[@"Add to cache"]; + if (![addCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Add to cache"); + } + [addCache tap]; + [menu tap]; + + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* cachePopup = + app.otherElements[@"{\"cacheKeys\":[\"test_caches_entry\"],\"localStorage\":{\"test_" + @"localStorage\":\"dummy_entry\"}}"]; + if (![cachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find cache pop up"); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/Info.plist b/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/ios/RunnerUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/webview_flutter/example/lib/main.dart b/packages/webview_flutter/webview_flutter/example/lib/main.dart new file mode 100644 index 000000000000..b660ce37a0b1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/lib/main.dart @@ -0,0 +1,355 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +void main() => runApp(MaterialApp(home: WebViewExample())); + +const String kNavigationExamplePage = ''' + +Navigation Delegate Example + +

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

+ + + +'''; + +class WebViewExample extends StatefulWidget { + @override + _WebViewExampleState createState() => _WebViewExampleState(); +} + +class _WebViewExampleState extends State { + final Completer _controller = + Completer(); + + @override + void initState() { + super.initState(); + if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter WebView example'), + // This drop down menu demonstrates that Flutter widgets can be shown over the web view. + actions: [ + NavigationControls(_controller.future), + SampleMenu(_controller.future), + ], + ), + // We're using a Builder here so we have a context that is below the Scaffold + // to allow calling Scaffold.of(context) so we can show a snackbar. + body: Builder(builder: (BuildContext context) { + return WebView( + initialUrl: 'https://flutter.dev', + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController webViewController) { + _controller.complete(webViewController); + }, + onProgress: (int progress) { + print("WebView is loading (progress : $progress%)"); + }, + javascriptChannels: { + _toasterJavascriptChannel(context), + }, + navigationDelegate: (NavigationRequest request) { + if (request.url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $request}'); + return NavigationDecision.prevent; + } + print('allowing navigation to $request'); + return NavigationDecision.navigate; + }, + onPageStarted: (String url) { + print('Page started loading: $url'); + }, + onPageFinished: (String url) { + print('Page finished loading: $url'); + }, + gestureNavigationEnabled: true, + ); + }), + floatingActionButton: favoriteButton(), + ); + } + + JavascriptChannel _toasterJavascriptChannel(BuildContext context) { + return JavascriptChannel( + name: 'Toaster', + onMessageReceived: (JavascriptMessage message) { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar(content: Text(message.message)), + ); + }); + } + + Widget favoriteButton() { + return FutureBuilder( + future: _controller.future, + builder: (BuildContext context, + AsyncSnapshot controller) { + if (controller.hasData) { + return FloatingActionButton( + onPressed: () async { + final String url = (await controller.data!.currentUrl())!; + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar(content: Text('Favorited $url')), + ); + }, + child: const Icon(Icons.favorite), + ); + } + return Container(); + }); + } +} + +enum MenuOptions { + showUserAgent, + listCookies, + clearCookies, + addToCache, + listCache, + clearCache, + navigationDelegate, +} + +class SampleMenu extends StatelessWidget { + SampleMenu(this.controller); + + final Future controller; + final CookieManager cookieManager = CookieManager(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: controller, + builder: + (BuildContext context, AsyncSnapshot controller) { + return PopupMenuButton( + onSelected: (MenuOptions value) { + switch (value) { + case MenuOptions.showUserAgent: + _onShowUserAgent(controller.data!, context); + break; + case MenuOptions.listCookies: + _onListCookies(controller.data!, context); + break; + case MenuOptions.clearCookies: + _onClearCookies(context); + break; + case MenuOptions.addToCache: + _onAddToCache(controller.data!, context); + break; + case MenuOptions.listCache: + _onListCache(controller.data!, context); + break; + case MenuOptions.clearCache: + _onClearCache(controller.data!, context); + break; + case MenuOptions.navigationDelegate: + _onNavigationDelegateExample(controller.data!, context); + break; + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: MenuOptions.showUserAgent, + child: const Text('Show user agent'), + enabled: controller.hasData, + ), + const PopupMenuItem( + value: MenuOptions.listCookies, + child: Text('List cookies'), + ), + const PopupMenuItem( + value: MenuOptions.clearCookies, + child: Text('Clear cookies'), + ), + const PopupMenuItem( + value: MenuOptions.addToCache, + child: Text('Add to cache'), + ), + const PopupMenuItem( + value: MenuOptions.listCache, + child: Text('List cache'), + ), + const PopupMenuItem( + value: MenuOptions.clearCache, + child: Text('Clear cache'), + ), + const PopupMenuItem( + value: MenuOptions.navigationDelegate, + child: Text('Navigation Delegate example'), + ), + ], + ); + }, + ); + } + + void _onShowUserAgent( + WebViewController controller, BuildContext context) async { + // Send a message with the user agent string to the Toaster JavaScript channel we registered + // with the WebView. + await controller.runJavascript( + 'Toaster.postMessage("User Agent: " + navigator.userAgent);'); + } + + void _onListCookies( + WebViewController controller, BuildContext context) async { + final String cookies = + await controller.runJavascriptReturningResult('document.cookie'); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(SnackBar( + content: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Cookies:'), + _getCookieList(cookies), + ], + ), + )); + } + + void _onAddToCache(WebViewController controller, BuildContext context) async { + await controller.runJavascript( + 'caches.open("test_caches_entry"); localStorage["test_localStorage"] = "dummy_entry";'); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(const SnackBar( + content: Text('Added a test entry to cache.'), + )); + } + + void _onListCache(WebViewController controller, BuildContext context) async { + await controller.runJavascript('caches.keys()' + '.then((cacheKeys) => JSON.stringify({"cacheKeys" : cacheKeys, "localStorage" : localStorage}))' + '.then((caches) => Toaster.postMessage(caches))'); + } + + void _onClearCache(WebViewController controller, BuildContext context) async { + await controller.clearCache(); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(const SnackBar( + content: Text("Cache cleared."), + )); + } + + void _onClearCookies(BuildContext context) async { + final bool hadCookies = await cookieManager.clearCookies(); + String message = 'There were cookies. Now, they are gone!'; + if (!hadCookies) { + message = 'There are no cookies.'; + } + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(SnackBar( + content: Text(message), + )); + } + + void _onNavigationDelegateExample( + WebViewController controller, BuildContext context) async { + final String contentBase64 = + base64Encode(const Utf8Encoder().convert(kNavigationExamplePage)); + await controller.loadUrl('data:text/html;base64,$contentBase64'); + } + + Widget _getCookieList(String cookies) { + if (cookies == null || cookies == '""') { + return Container(); + } + final List cookieList = cookies.split(';'); + final Iterable cookieWidgets = + cookieList.map((String cookie) => Text(cookie)); + return Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: cookieWidgets.toList(), + ); + } +} + +class NavigationControls extends StatelessWidget { + const NavigationControls(this._webViewControllerFuture) + : assert(_webViewControllerFuture != null); + + final Future _webViewControllerFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _webViewControllerFuture, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + final bool webViewReady = + snapshot.connectionState == ConnectionState.done; + final WebViewController? controller = snapshot.data; + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller!.canGoBack()) { + await controller.goBack(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar(content: Text("No back history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller!.canGoForward()) { + await controller.goForward(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar( + content: Text("No forward history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.replay), + onPressed: !webViewReady + ? null + : () { + controller!.reload(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/packages/webview_flutter/webview_flutter/example/pubspec.yaml b/packages/webview_flutter/webview_flutter/example/pubspec.yaml new file mode 100644 index 000000000000..6b668eb96af3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/pubspec.yaml @@ -0,0 +1,34 @@ +name: webview_flutter_example +description: Demonstrates how to use the webview_flutter plugin. +publish_to: none + +environment: + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + webview_flutter: + # When depending on this package from a real application you should use: + # webview_flutter: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + espresso: ^0.1.0+2 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter + pedantic: ^1.10.0 + +flutter: + uses-material-design: true + assets: + - assets/sample_audio.ogg + - assets/sample_video.mp4 diff --git a/packages/webview_flutter/webview_flutter/example/test_driver/integration_test.dart b/packages/webview_flutter/webview_flutter/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/webview_flutter/webview_flutter/lib/platform_interface.dart b/packages/webview_flutter/webview_flutter/lib/platform_interface.dart new file mode 100644 index 000000000000..aa7b3a0931e8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/lib/platform_interface.dart @@ -0,0 +1,24 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Re-export the classes from the webview_flutter_platform_interface through +/// the `platform_interface.dart` file so we don't accidentally break any +/// non-endorsed existing implementations of the interface. +export 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart' + show + AutoMediaPlaybackPolicy, + CreationParams, + JavascriptChannel, + JavascriptChannelRegistry, + JavascriptMessage, + JavascriptMode, + JavascriptMessageHandler, + WebViewPlatform, + WebViewPlatformCallbacksHandler, + WebViewPlatformController, + WebViewPlatformCreatedCallback, + WebSetting, + WebSettings, + WebResourceError, + WebResourceErrorType; diff --git a/packages/webview_flutter/webview_flutter/lib/src/webview.dart b/packages/webview_flutter/webview_flutter/lib/src/webview.dart new file mode 100644 index 000000000000..50b4d2e94d93 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/lib/src/webview.dart @@ -0,0 +1,759 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_android/webview_android.dart'; +import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +import '../platform_interface.dart'; + +/// Optional callback invoked when a web view is first created. [controller] is +/// the [WebViewController] for the created web view. +typedef void WebViewCreatedCallback(WebViewController controller); + +/// Information about a navigation action that is about to be executed. +class NavigationRequest { + NavigationRequest._({required this.url, required this.isForMainFrame}); + + /// The URL that will be loaded if the navigation is executed. + final String url; + + /// Whether the navigation request is to be loaded as the main frame. + final bool isForMainFrame; + + @override + String toString() { + return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; + } +} + +/// A decision on how to handle a navigation request. +enum NavigationDecision { + /// Prevent the navigation from taking place. + prevent, + + /// Allow the navigation to take place. + navigate, +} + +/// Decides how to handle a specific navigation request. +/// +/// The returned [NavigationDecision] determines how the navigation described by +/// `navigation` should be handled. +/// +/// See also: [WebView.navigationDelegate]. +typedef FutureOr NavigationDelegate( + NavigationRequest navigation); + +/// Signature for when a [WebView] has started loading a page. +typedef void PageStartedCallback(String url); + +/// Signature for when a [WebView] has finished loading a page. +typedef void PageFinishedCallback(String url); + +/// Signature for when a [WebView] is loading a page. +typedef void PageLoadingCallback(int progress); + +/// Signature for when a [WebView] has failed to load a resource. +typedef void WebResourceErrorCallback(WebResourceError error); + +/// A web view widget for showing html content. +/// +/// There is a known issue that on iOS 13.4 and 13.5, other flutter widgets covering +/// the `WebView` is not able to block the `WebView` from receiving touch events. +/// See https://github.com/flutter/flutter/issues/53490. +class WebView extends StatefulWidget { + /// Creates a new web view. + /// + /// The web view can be controlled using a `WebViewController` that is passed to the + /// `onWebViewCreated` callback once the web view is created. + /// + /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. + const WebView({ + Key? key, + this.onWebViewCreated, + this.initialUrl, + this.javascriptMode = JavascriptMode.disabled, + this.javascriptChannels, + this.navigationDelegate, + this.gestureRecognizers, + this.onPageStarted, + this.onPageFinished, + this.onProgress, + this.onWebResourceError, + this.debuggingEnabled = false, + this.gestureNavigationEnabled = false, + this.userAgent, + this.zoomEnabled = true, + this.initialMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + this.allowsInlineMediaPlayback = false, + }) : assert(javascriptMode != null), + assert(initialMediaPlaybackPolicy != null), + assert(allowsInlineMediaPlayback != null), + super(key: key); + + static WebViewPlatform? _platform; + + /// Sets a custom [WebViewPlatform]. + /// + /// This property can be set to use a custom platform implementation for WebViews. + /// + /// Setting `platform` doesn't affect [WebView]s that were already created. + /// + /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. + static set platform(WebViewPlatform? platform) { + _platform = platform; + } + + /// The WebView platform that's used by this WebView. + /// + /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. + static WebViewPlatform get platform { + if (_platform == null) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + _platform = AndroidWebView(); + break; + case TargetPlatform.iOS: + _platform = CupertinoWebView(); + break; + default: + throw UnsupportedError( + "Trying to use the default webview implementation for $defaultTargetPlatform but there isn't a default one"); + } + } + return _platform!; + } + + /// If not null invoked once the web view is created. + final WebViewCreatedCallback? onWebViewCreated; + + /// Which gestures should be consumed by the web view. + /// + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + /// The initial URL to load. + final String? initialUrl; + + /// Whether JavaScript execution is enabled. + final JavascriptMode javascriptMode; + + /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. + /// + /// For each [JavascriptChannel] in the set, a channel object is made available for the + /// JavaScript code in a window property named [JavascriptChannel.name]. + /// The JavaScript code can then call `postMessage` on that object to send a message that will be + /// passed to [JavascriptChannel.onMessageReceived]. + /// + /// For example for the following JavascriptChannel: + /// + /// ```dart + /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); + /// ``` + /// + /// JavaScript code can call: + /// + /// ```javascript + /// Print.postMessage('Hello'); + /// ``` + /// + /// To asynchronously invoke the message handler which will print the message to standard output. + /// + /// Adding a new JavaScript channel only takes affect after the next page is loaded. + /// + /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple + /// channels in the list. + /// + /// A null value is equivalent to an empty set. + final Set? javascriptChannels; + + /// A delegate function that decides how to handle navigation actions. + /// + /// When a navigation is initiated by the WebView (e.g when a user clicks a link) + /// this delegate is called and has to decide how to proceed with the navigation. + /// + /// See [NavigationDecision] for possible decisions the delegate can take. + /// + /// When null all navigation actions are allowed. + /// + /// Caveats on Android: + /// + /// * Navigation actions targeted to the main frame can be intercepted, + /// navigation actions targeted to subframes are allowed regardless of the value + /// returned by this delegate. + /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were + /// triggered by a user gesture, this disables some of Chromium's security mechanisms. + /// A navigationDelegate should only be set when loading trusted content. + /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have + /// a later version): + /// * When a navigationDelegate is set pages with frames are not properly handled by the + /// webview, and frames will be opened in the main frame. + /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. + final NavigationDelegate? navigationDelegate; + + /// Controls whether inline playback of HTML5 videos is allowed on iOS. + /// + /// This field is ignored on Android because Android allows it by default. + /// + /// By default `allowsInlineMediaPlayback` is false. + final bool allowsInlineMediaPlayback; + + /// Invoked when a page starts loading. + final PageStartedCallback? onPageStarted; + + /// Invoked when a page has finished loading. + /// + /// This is invoked only for the main frame. + /// + /// When [onPageFinished] is invoked on Android, the page being rendered may + /// not be updated yet. + /// + /// When invoked on iOS or Android, any JavaScript code that is embedded + /// directly in the HTML has been loaded and code injected with + /// [WebViewController.runJavascript] or [WebViewController.runJavascriptReturningResult] can assume this. + final PageFinishedCallback? onPageFinished; + + /// Invoked when a page is loading. + final PageLoadingCallback? onProgress; + + /// Invoked when a web resource has failed to load. + /// + /// This callback is only called for the main page. + final WebResourceErrorCallback? onWebResourceError; + + /// Controls whether WebView debugging is enabled. + /// + /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). + /// + /// WebView debugging is enabled by default in dev builds on iOS. + /// + /// To debug WebViews on iOS: + /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) + /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> + /// + /// By default `debuggingEnabled` is false. + final bool debuggingEnabled; + + /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. + /// + /// This only works on iOS. + /// + /// By default `gestureNavigationEnabled` is false. + final bool gestureNavigationEnabled; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + /// + /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. + /// + /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. + /// + /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom + /// user agent. + /// + /// By default `userAgent` is null. + final String? userAgent; + + /// A Boolean value indicating whether the WebView should support zooming + /// using its on-screen zoom controls and gestures. + /// + /// By default 'zoomEnabled' is true + final bool zoomEnabled; + + /// Which restrictions apply on automatic media playback. + /// + /// This initial value is applied to the platform's webview upon creation. Any following + /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). + /// + /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. + final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; + + @override + State createState() => _WebViewState(); +} + +class _WebViewState extends State { + final Completer _controller = + Completer(); + + late JavascriptChannelRegistry _javascriptChannelRegistry; + late _PlatformCallbacksHandler _platformCallbacksHandler; + + @override + Widget build(BuildContext context) { + return WebView.platform.build( + context: context, + onWebViewPlatformCreated: _onWebViewPlatformCreated, + webViewPlatformCallbacksHandler: _platformCallbacksHandler, + javascriptChannelRegistry: _javascriptChannelRegistry, + gestureRecognizers: widget.gestureRecognizers, + creationParams: _creationParamsfromWidget(widget), + ); + } + + @override + void initState() { + super.initState(); + _assertJavascriptChannelNamesAreUnique(); + _platformCallbacksHandler = _PlatformCallbacksHandler(widget); + _javascriptChannelRegistry = + JavascriptChannelRegistry(widget.javascriptChannels); + } + + @override + void didUpdateWidget(WebView oldWidget) { + super.didUpdateWidget(oldWidget); + _assertJavascriptChannelNamesAreUnique(); + _controller.future.then((WebViewController controller) { + _platformCallbacksHandler._widget = widget; + controller._updateWidget(widget); + }); + } + + void _onWebViewPlatformCreated(WebViewPlatformController? webViewPlatform) { + final WebViewController controller = WebViewController._( + widget, + webViewPlatform!, + _javascriptChannelRegistry, + ); + _controller.complete(controller); + if (widget.onWebViewCreated != null) { + widget.onWebViewCreated!(controller); + } + } + + void _assertJavascriptChannelNamesAreUnique() { + if (widget.javascriptChannels == null || + widget.javascriptChannels!.isEmpty) { + return; + } + assert(_extractChannelNames(widget.javascriptChannels).length == + widget.javascriptChannels!.length); + } +} + +CreationParams _creationParamsfromWidget(WebView widget) { + return CreationParams( + initialUrl: widget.initialUrl, + webSettings: _webSettingsFromWidget(widget), + javascriptChannelNames: _extractChannelNames(widget.javascriptChannels), + userAgent: widget.userAgent, + autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, + ); +} + +WebSettings _webSettingsFromWidget(WebView widget) { + return WebSettings( + javascriptMode: widget.javascriptMode, + hasNavigationDelegate: widget.navigationDelegate != null, + hasProgressTracking: widget.onProgress != null, + debuggingEnabled: widget.debuggingEnabled, + gestureNavigationEnabled: widget.gestureNavigationEnabled, + allowsInlineMediaPlayback: widget.allowsInlineMediaPlayback, + userAgent: WebSetting.of(widget.userAgent), + zoomEnabled: widget.zoomEnabled, + ); +} + +// This method assumes that no fields in `currentValue` are null. +WebSettings _clearUnchangedWebSettings( + WebSettings currentValue, WebSettings newValue) { + assert(currentValue.javascriptMode != null); + assert(currentValue.hasNavigationDelegate != null); + assert(currentValue.hasProgressTracking != null); + assert(currentValue.debuggingEnabled != null); + assert(currentValue.userAgent != null); + assert(newValue.javascriptMode != null); + assert(newValue.hasNavigationDelegate != null); + assert(newValue.debuggingEnabled != null); + assert(newValue.userAgent != null); + assert(newValue.zoomEnabled != null); + + JavascriptMode? javascriptMode; + bool? hasNavigationDelegate; + bool? hasProgressTracking; + bool? debuggingEnabled; + WebSetting userAgent = WebSetting.absent(); + bool? zoomEnabled; + if (currentValue.javascriptMode != newValue.javascriptMode) { + javascriptMode = newValue.javascriptMode; + } + if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { + hasNavigationDelegate = newValue.hasNavigationDelegate; + } + if (currentValue.hasProgressTracking != newValue.hasProgressTracking) { + hasProgressTracking = newValue.hasProgressTracking; + } + if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { + debuggingEnabled = newValue.debuggingEnabled; + } + if (currentValue.userAgent != newValue.userAgent) { + userAgent = newValue.userAgent; + } + if (currentValue.zoomEnabled != newValue.zoomEnabled) { + zoomEnabled = newValue.zoomEnabled; + } + + return WebSettings( + javascriptMode: javascriptMode, + hasNavigationDelegate: hasNavigationDelegate, + hasProgressTracking: hasProgressTracking, + debuggingEnabled: debuggingEnabled, + userAgent: userAgent, + zoomEnabled: zoomEnabled, + ); +} + +Set _extractChannelNames(Set? channels) { + final Set channelNames = channels == null + ? {} + : channels.map((JavascriptChannel channel) => channel.name).toSet(); + return channelNames; +} + +class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { + _PlatformCallbacksHandler(this._widget); + + WebView _widget; + + @override + FutureOr onNavigationRequest({ + required String url, + required bool isForMainFrame, + }) async { + final NavigationRequest request = + NavigationRequest._(url: url, isForMainFrame: isForMainFrame); + final bool allowNavigation = _widget.navigationDelegate == null || + await _widget.navigationDelegate!(request) == + NavigationDecision.navigate; + return allowNavigation; + } + + @override + void onPageStarted(String url) { + if (_widget.onPageStarted != null) { + _widget.onPageStarted!(url); + } + } + + @override + void onPageFinished(String url) { + if (_widget.onPageFinished != null) { + _widget.onPageFinished!(url); + } + } + + @override + void onProgress(int progress) { + if (_widget.onProgress != null) { + _widget.onProgress!(progress); + } + } + + void onWebResourceError(WebResourceError error) { + if (_widget.onWebResourceError != null) { + _widget.onWebResourceError!(error); + } + } +} + +/// Controls a [WebView]. +/// +/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] +/// callback for a [WebView] widget. +class WebViewController { + WebViewController._( + this._widget, + this._webViewPlatformController, + this._javascriptChannelRegistry, + ) : assert(_webViewPlatformController != null) { + _settings = _webSettingsFromWidget(_widget); + } + + final WebViewPlatformController _webViewPlatformController; + final JavascriptChannelRegistry _javascriptChannelRegistry; + + late WebSettings _settings; + + WebView _widget; + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, { + Map? headers, + }) async { + assert(url != null); + _validateUrlString(url); + return _webViewPlatformController.loadUrl(url, headers); + } + + /// Load html file from assets + /// + /// `url` must not be null. + Future loadAssetHtmlFile(String url) async { + assert(url != null); + return _webViewPlatformController.loadAssetHtmlFile(url); + } + + /// Load html file from local path + /// + /// `url` must not be null. + Future loadLocalHtmlFile(String url) async { + assert(url != null); + return _webViewPlatformController.loadLocalHtmlFile(url); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If [WebView.initialUrl] was never specified, returns `null`. + /// Note that this operation is asynchronous, and it is possible that the + /// current URL changes again by the time this function returns (in other + /// words, by the time this future completes, the WebView may be displaying a + /// different URL). + Future currentUrl() { + return _webViewPlatformController.currentUrl(); + } + + /// Checks whether there's a back history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has + /// changed by the time the future completed. + Future canGoBack() { + return _webViewPlatformController.canGoBack(); + } + + /// Checks whether there's a forward history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has + /// changed by the time the future completed. + Future canGoForward() { + return _webViewPlatformController.canGoForward(); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + return _webViewPlatformController.goBack(); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + return _webViewPlatformController.goForward(); + } + + /// Reloads the current URL. + Future reload() { + return _webViewPlatformController.reload(); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + /// + /// Note: Calling this method also triggers a reload. + Future clearCache() async { + await _webViewPlatformController.clearCache(); + return reload(); + } + + Future _updateJavascriptChannels( + Set? newChannels) async { + final Set currentChannels = + _javascriptChannelRegistry.channels.keys.toSet(); + final Set newChannelNames = _extractChannelNames(newChannels); + final Set channelsToAdd = + newChannelNames.difference(currentChannels); + final Set channelsToRemove = + currentChannels.difference(newChannelNames); + if (channelsToRemove.isNotEmpty) { + await _webViewPlatformController + .removeJavascriptChannels(channelsToRemove); + } + if (channelsToAdd.isNotEmpty) { + await _webViewPlatformController.addJavascriptChannels(channelsToAdd); + } + _javascriptChannelRegistry.updateJavascriptChannelsFromSet(newChannels); + } + + Future _updateWidget(WebView widget) async { + _widget = widget; + await _updateSettings(_webSettingsFromWidget(widget)); + await _updateJavascriptChannels(widget.javascriptChannels); + } + + Future _updateSettings(WebSettings newSettings) { + final WebSettings update = + _clearUnchangedWebSettings(_settings, newSettings); + _settings = newSettings; + return _webViewPlatformController.updateSettings(update); + } + + /// Evaluates a JavaScript expression in the context of the current page. + /// + /// On Android returns the evaluation result as a JSON formatted string. + /// + /// On iOS depending on the value type the return value would be one of: + /// + /// - For primitive JavaScript types: the value string formatted + /// (e.g JavaScript 100 returns '100'). + /// - For JavaScript arrays of supported types: a string formatted NSArray + /// (e.g '(1,2,3), note that the string for NSArray is formatted and might + /// contain newlines and extra spaces.'). + /// - Other non-primitive types are not supported on iOS and will complete + /// the Future with an error. + /// + /// The Future completes with an error if a JavaScript error occurred, + /// or on iOS, if the type of the evaluated expression is + /// not supported as described above. + /// + /// When evaluating JavaScript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the JavaScript + /// embedded in the main frame HTML has been loaded. + @Deprecated('Use [runJavascript] or [runJavascriptReturningResult]') + Future evaluateJavascript(String javascriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); + } + return _webViewPlatformController.evaluateJavascript(javascriptString); + } + + /// Runs the given JavaScript in the context of the current page. + /// If you are looking for the result, use [runJavascriptReturningResult] instead. + /// The Future completes with an error if a JavaScript error occurred. + /// + /// When running JavaScript in a [WebView], it is best practice to wait for + // the [WebView.onPageFinished] callback. This guarantees all the JavaScript + // embedded in the main frame HTML has been loaded. + Future runJavascript(String javaScriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling runJavascript.')); + } + return _webViewPlatformController.runJavascript(javaScriptString); + } + + /// Runs the given JavaScript in the context of the current page, + /// and returns the result. + /// + /// On Android returns the evaluation result as a JSON formatted string. + /// + /// On iOS depending on the value type the return value would be one of: + /// + /// - For primitive JavaScript types: the value string formatted + /// (e.g JavaScript 100 returns '100'). + /// - For JavaScript arrays of supported types: a string formatted NSArray + /// (e.g '(1,2,3), note that the string for NSArray is formatted and might + /// contain newlines and extra spaces.'). + /// + /// The Future completes with an error if a JavaScript error occurred, + /// or if the type the given expression evaluates to is unsupported. + /// Unsupported values include certain non primitive types on iOS, as well as + /// `undefined` or `null` on iOS 14+. + /// + /// When evaluating JavaScript in a [WebView], it is best practice to wait + /// for the [WebView.onPageFinished] callback. This guarantees all the + /// JavaScript embedded in the main frame HTML has been loaded. + Future runJavascriptReturningResult(String javaScriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling runJavascriptReturningResult.')); + } + return _webViewPlatformController + .runJavascriptReturningResult(javaScriptString); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + return _webViewPlatformController.getTitle(); + } + + /// Sets the WebView's content scroll position. + /// + /// The parameters `x` and `y` specify the scroll position in WebView pixels. + Future scrollTo(int x, int y) { + return _webViewPlatformController.scrollTo(x, y); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by horizontally and vertically respectively. + Future scrollBy(int x, int y) { + return _webViewPlatformController.scrollBy(x, y); + } + + /// Return the horizontal scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + return _webViewPlatformController.getScrollX(); + } + + /// Return the vertical scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + return _webViewPlatformController.getScrollY(); + } +} + +/// Manages cookies pertaining to all [WebView]s. +class CookieManager { + /// Creates a [CookieManager] -- returns the instance if it's already been called. + factory CookieManager() { + return _instance ??= CookieManager._(); + } + + CookieManager._(); + + static CookieManager? _instance; + + /// Clears all cookies for all [WebView] instances. + /// + /// This is a no op on iOS version smaller than 9. + /// + /// Returns true if cookies were present before clearing, else false. + Future clearCookies() => WebView.platform.clearCookies(); +} + +// Throws an ArgumentError if `url` is not a valid URL string. +void _validateUrlString(String url) { + try { + final Uri uri = Uri.parse(url); + if (uri.scheme.isEmpty) { + throw ArgumentError('Missing scheme in URL string: "$url"'); + } + } on FormatException catch (e) { + throw ArgumentError(e); + } +} diff --git a/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart b/packages/webview_flutter/webview_flutter/lib/webview_flutter.dart new file mode 100644 index 000000000000..ba38771e5107 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/lib/webview_flutter.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 'package:webview_flutter_android/webview_android.dart'; +export 'package:webview_flutter_android/webview_surface_android.dart'; +export 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +export 'platform_interface.dart'; +export 'src/webview.dart'; diff --git a/packages/webview_flutter/webview_flutter/pubspec.yaml b/packages/webview_flutter/webview_flutter/pubspec.yaml new file mode 100644 index 000000000000..c7ac2e0200f9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/pubspec.yaml @@ -0,0 +1,31 @@ +name: webview_flutter +description: A Flutter plugin that provides a WebView widget on Android and iOS. +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 +version: 2.3.0 + +environment: + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" + +flutter: + plugin: + platforms: + android: + default_package: webview_flutter_android + ios: + default_package: webview_flutter_wkwebview + +dependencies: + flutter: + sdk: flutter + webview_flutter_android: ^2.2.0 + webview_flutter_platform_interface: ^1.2.0 + webview_flutter_wkwebview: ^2.2.0 + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + pedantic: ^1.10.0 diff --git a/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart new file mode 100644 index 000000000000..e325f68dc0b7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter/test/webview_flutter_test.dart @@ -0,0 +1,1390 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter/src/foundation/basic_types.dart'; +import 'package:flutter/src/gestures/recognizer.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +typedef void VoidCallback(); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final _FakePlatformViewsController fakePlatformViewsController = + _FakePlatformViewsController(); + + final _FakeCookieManager _fakeCookieManager = _FakeCookieManager(); + + setUpAll(() { + SystemChannels.platform_views.setMockMethodCallHandler( + fakePlatformViewsController.fakePlatformViewsMethodHandler); + SystemChannels.platform + .setMockMethodCallHandler(_fakeCookieManager.onMethodCall); + }); + + setUp(() { + fakePlatformViewsController.reset(); + _fakeCookieManager.reset(); + }); + + testWidgets('Create WebView', (WidgetTester tester) async { + await tester.pumpWidget(const WebView()); + }); + + testWidgets('Initial url', (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(await controller.currentUrl(), 'https://youtube.com'); + }); + + testWidgets('Javascript mode', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + javascriptMode: JavascriptMode.unrestricted, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.javascriptMode, JavascriptMode.unrestricted); + + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + javascriptMode: JavascriptMode.disabled, + )); + expect(platformWebView.javascriptMode, JavascriptMode.disabled); + }); + + testWidgets('Load url', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + await controller!.loadUrl('https://flutter.io'); + + expect(await controller!.currentUrl(), 'https://flutter.io'); + }); + + testWidgets('Invalid urls', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + expect(await controller!.currentUrl(), isNull); + + expect(() => controller!.loadUrl(''), throwsA(anything)); + expect(await controller!.currentUrl(), isNull); + + // Missing schema. + expect(() => controller!.loadUrl('flutter.io'), throwsA(anything)); + expect(await controller!.currentUrl(), isNull); + }); + + testWidgets('Headers in loadUrl', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + final Map headers = { + 'CACHE-CONTROL': 'ABC' + }; + await controller!.loadUrl('https://flutter.io', headers: headers); + expect(await controller!.currentUrl(), equals('https://flutter.io')); + }); + + testWidgets("Can't go back before loading a page", + (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + final bool canGoBackNoPageLoaded = await controller!.canGoBack(); + + expect(canGoBackNoPageLoaded, false); + }); + + testWidgets("Clear Cache", (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + expect(fakePlatformViewsController.lastCreatedView!.hasCache, true); + + await controller!.clearCache(); + + expect(fakePlatformViewsController.lastCreatedView!.hasCache, false); + }); + + testWidgets("Can't go back with no history", (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + final bool canGoBackFirstPageLoaded = await controller!.canGoBack(); + + expect(canGoBackFirstPageLoaded, false); + }); + + testWidgets('Can go back', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + await controller!.loadUrl('https://www.google.com'); + final bool canGoBackSecondPageLoaded = await controller!.canGoBack(); + + expect(canGoBackSecondPageLoaded, true); + }); + + testWidgets("Can't go forward before loading a page", + (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + final bool canGoForwardNoPageLoaded = await controller!.canGoForward(); + + expect(canGoForwardNoPageLoaded, false); + }); + + testWidgets("Can't go forward with no history", (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + final bool canGoForwardFirstPageLoaded = await controller!.canGoForward(); + + expect(canGoForwardFirstPageLoaded, false); + }); + + testWidgets('Can go forward', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + await controller!.loadUrl('https://youtube.com'); + await controller!.goBack(); + final bool canGoForwardFirstPageBacked = await controller!.canGoForward(); + + expect(canGoForwardFirstPageBacked, true); + }); + + testWidgets('Go back', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + expect(await controller!.currentUrl(), 'https://youtube.com'); + + await controller!.loadUrl('https://flutter.io'); + + expect(await controller!.currentUrl(), 'https://flutter.io'); + + await controller!.goBack(); + + expect(await controller!.currentUrl(), 'https://youtube.com'); + }); + + testWidgets('Go forward', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + expect(await controller!.currentUrl(), 'https://youtube.com'); + + await controller!.loadUrl('https://flutter.io'); + + expect(await controller!.currentUrl(), 'https://flutter.io'); + + await controller!.goBack(); + + expect(await controller!.currentUrl(), 'https://youtube.com'); + + await controller!.goForward(); + + expect(await controller!.currentUrl(), 'https://flutter.io'); + }); + + testWidgets('Current URL', (WidgetTester tester) async { + WebViewController? controller; + await tester.pumpWidget( + WebView( + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + expect(controller, isNotNull); + + // Test a WebView without an explicitly set first URL. + expect(await controller!.currentUrl(), isNull); + + await controller!.loadUrl('https://youtube.com'); + expect(await controller!.currentUrl(), 'https://youtube.com'); + + await controller!.loadUrl('https://flutter.io'); + expect(await controller!.currentUrl(), 'https://flutter.io'); + + await controller!.goBack(); + expect(await controller!.currentUrl(), 'https://youtube.com'); + }); + + testWidgets('Reload url', (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.currentUrl, 'https://flutter.io'); + expect(platformWebView.amountOfReloadsOnCurrentUrl, 0); + + await controller.reload(); + + expect(platformWebView.currentUrl, 'https://flutter.io'); + expect(platformWebView.amountOfReloadsOnCurrentUrl, 1); + + await controller.loadUrl('https://youtube.com'); + + expect(platformWebView.amountOfReloadsOnCurrentUrl, 0); + }); + + testWidgets('evaluate Javascript', (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + expect( + // ignore: deprecated_member_use_from_same_package + await controller.evaluateJavascript("fake js string"), + "fake js string", + reason: 'should get the argument'); + }); + + testWidgets('evaluate Javascript with JavascriptMode disabled', + (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + javascriptMode: JavascriptMode.disabled, + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + expect( + // ignore: deprecated_member_use_from_same_package + () => controller.evaluateJavascript('fake js string'), + throwsA(anything), + ); + }); + + testWidgets('runJavaScript', (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + await controller.runJavascript('fake js string'); + expect(fakePlatformViewsController.lastCreatedView?.lastRunJavaScriptString, + 'fake js string'); + }); + + testWidgets('runJavaScript with JavascriptMode disabled', + (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + javascriptMode: JavascriptMode.disabled, + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + expect( + () => controller.runJavascript('fake js string'), + throwsA(anything), + ); + }); + + testWidgets('runJavaScriptReturningResult', (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + expect(await controller.runJavascriptReturningResult("fake js string"), + "fake js string", + reason: 'should get the argument'); + }); + + testWidgets('runJavaScriptReturningResult with JavascriptMode disabled', + (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://flutter.io', + javascriptMode: JavascriptMode.disabled, + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + expect( + () => controller.runJavascriptReturningResult('fake js string'), + throwsA(anything), + ); + }); + + testWidgets('Cookies can be cleared once', (WidgetTester tester) async { + await tester.pumpWidget( + const WebView( + initialUrl: 'https://flutter.io', + ), + ); + final CookieManager cookieManager = CookieManager(); + final bool hasCookies = await cookieManager.clearCookies(); + expect(hasCookies, true); + }); + + testWidgets('Second cookie clear does not have cookies', + (WidgetTester tester) async { + await tester.pumpWidget( + const WebView( + initialUrl: 'https://flutter.io', + ), + ); + final CookieManager cookieManager = CookieManager(); + final bool hasCookies = await cookieManager.clearCookies(); + expect(hasCookies, true); + final bool hasCookiesSecond = await cookieManager.clearCookies(); + expect(hasCookiesSecond, false); + }); + + testWidgets('Initial JavaScript channels', (WidgetTester tester) async { + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + javascriptChannels: { + JavascriptChannel( + name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}), + JavascriptChannel( + name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}), + }, + ), + ); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.javascriptChannelNames, + unorderedEquals(['Tts', 'Alarm'])); + }); + + test('Only valid JavaScript channel names are allowed', () { + final JavascriptMessageHandler noOp = (JavascriptMessage msg) {}; + JavascriptChannel(name: 'Tts1', onMessageReceived: noOp); + JavascriptChannel(name: '_Alarm', onMessageReceived: noOp); + JavascriptChannel(name: 'foo_bar_', onMessageReceived: noOp); + + VoidCallback createChannel(String name) { + return () { + JavascriptChannel(name: name, onMessageReceived: noOp); + }; + } + + expect(createChannel('1Alarm'), throwsAssertionError); + expect(createChannel('foo.bar'), throwsAssertionError); + expect(createChannel(''), throwsAssertionError); + }); + + testWidgets('Unique JavaScript channel names are required', + (WidgetTester tester) async { + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + javascriptChannels: { + JavascriptChannel( + name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}), + JavascriptChannel( + name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}), + }, + ), + ); + expect(tester.takeException(), isNot(null)); + }); + + testWidgets('JavaScript channels update', (WidgetTester tester) async { + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + javascriptChannels: { + JavascriptChannel( + name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}), + JavascriptChannel( + name: 'Alarm', onMessageReceived: (JavascriptMessage msg) {}), + }, + ), + ); + + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + javascriptChannels: { + JavascriptChannel( + name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}), + JavascriptChannel( + name: 'Alarm2', onMessageReceived: (JavascriptMessage msg) {}), + JavascriptChannel( + name: 'Alarm3', onMessageReceived: (JavascriptMessage msg) {}), + }, + ), + ); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.javascriptChannelNames, + unorderedEquals(['Tts', 'Alarm2', 'Alarm3'])); + }); + + testWidgets('Remove all JavaScript channels and then add', + (WidgetTester tester) async { + // This covers a specific bug we had where after updating javascriptChannels to null, + // updating it again with a subset of the previously registered channels fails as the + // widget's cache of current channel wasn't properly updated when updating javascriptChannels to + // null. + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + javascriptChannels: { + JavascriptChannel( + name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}), + }, + ), + ); + + await tester.pumpWidget( + const WebView( + initialUrl: 'https://youtube.com', + ), + ); + + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + javascriptChannels: { + JavascriptChannel( + name: 'Tts', onMessageReceived: (JavascriptMessage msg) {}), + }, + ), + ); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.javascriptChannelNames, + unorderedEquals(['Tts'])); + }); + + testWidgets('JavaScript channel messages', (WidgetTester tester) async { + final List ttsMessagesReceived = []; + final List alarmMessagesReceived = []; + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + javascriptChannels: { + JavascriptChannel( + name: 'Tts', + onMessageReceived: (JavascriptMessage msg) { + ttsMessagesReceived.add(msg.message); + }), + JavascriptChannel( + name: 'Alarm', + onMessageReceived: (JavascriptMessage msg) { + alarmMessagesReceived.add(msg.message); + }), + }, + ), + ); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(ttsMessagesReceived, isEmpty); + expect(alarmMessagesReceived, isEmpty); + + platformWebView.fakeJavascriptPostMessage('Tts', 'Hello'); + platformWebView.fakeJavascriptPostMessage('Tts', 'World'); + + expect(ttsMessagesReceived, ['Hello', 'World']); + }); + + group('$PageStartedCallback', () { + testWidgets('onPageStarted is not null', (WidgetTester tester) async { + String? returnedUrl; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageStarted: (String url) { + returnedUrl = url; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + platformWebView.fakeOnPageStartedCallback(); + + expect(platformWebView.currentUrl, returnedUrl); + }); + + testWidgets('onPageStarted is null', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + onPageStarted: null, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + // The platform side will always invoke a call for onPageStarted. This is + // to test that it does not crash on a null callback. + platformWebView.fakeOnPageStartedCallback(); + }); + + testWidgets('onPageStarted changed', (WidgetTester tester) async { + String? returnedUrl; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageStarted: (String url) {}, + )); + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageStarted: (String url) { + returnedUrl = url; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + platformWebView.fakeOnPageStartedCallback(); + + expect(platformWebView.currentUrl, returnedUrl); + }); + }); + + group('$PageFinishedCallback', () { + testWidgets('onPageFinished is not null', (WidgetTester tester) async { + String? returnedUrl; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageFinished: (String url) { + returnedUrl = url; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + platformWebView.fakeOnPageFinishedCallback(); + + expect(platformWebView.currentUrl, returnedUrl); + }); + + testWidgets('onPageFinished is null', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + onPageFinished: null, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + // The platform side will always invoke a call for onPageFinished. This is + // to test that it does not crash on a null callback. + platformWebView.fakeOnPageFinishedCallback(); + }); + + testWidgets('onPageFinished changed', (WidgetTester tester) async { + String? returnedUrl; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageFinished: (String url) {}, + )); + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onPageFinished: (String url) { + returnedUrl = url; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + platformWebView.fakeOnPageFinishedCallback(); + + expect(platformWebView.currentUrl, returnedUrl); + }); + }); + + group('$PageLoadingCallback', () { + testWidgets('onLoadingProgress is not null', (WidgetTester tester) async { + int? loadingProgress; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onProgress: (int progress) { + loadingProgress = progress; + }, + )); + + final FakePlatformWebView? platformWebView = + fakePlatformViewsController.lastCreatedView; + + platformWebView?.fakeOnProgressCallback(50); + + expect(loadingProgress, 50); + }); + + testWidgets('onLoadingProgress is null', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + onProgress: null, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + // This is to test that it does not crash on a null callback. + platformWebView.fakeOnProgressCallback(50); + }); + + testWidgets('onLoadingProgress changed', (WidgetTester tester) async { + int? loadingProgress; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onProgress: (int progress) {}, + )); + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + onProgress: (int progress) { + loadingProgress = progress; + }, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + platformWebView.fakeOnProgressCallback(50); + + expect(loadingProgress, 50); + }); + }); + + group('navigationDelegate', () { + testWidgets('hasNavigationDelegate', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.hasNavigationDelegate, false); + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + navigationDelegate: (NavigationRequest r) => + NavigationDecision.navigate, + )); + + expect(platformWebView.hasNavigationDelegate, true); + }); + + testWidgets('Block navigation', (WidgetTester tester) async { + final List navigationRequests = []; + + await tester.pumpWidget(WebView( + initialUrl: 'https://youtube.com', + navigationDelegate: (NavigationRequest request) { + navigationRequests.add(request); + // Only allow navigating to https://flutter.dev + return request.url == 'https://flutter.dev' + ? NavigationDecision.navigate + : NavigationDecision.prevent; + })); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.hasNavigationDelegate, true); + + platformWebView.fakeNavigate('https://www.google.com'); + // The navigation delegate only allows navigation to https://flutter.dev + // so we should still be in https://youtube.com. + expect(platformWebView.currentUrl, 'https://youtube.com'); + expect(navigationRequests.length, 1); + expect(navigationRequests[0].url, 'https://www.google.com'); + expect(navigationRequests[0].isForMainFrame, true); + + platformWebView.fakeNavigate('https://flutter.dev'); + await tester.pump(); + expect(platformWebView.currentUrl, 'https://flutter.dev'); + }); + }); + + group('debuggingEnabled', () { + testWidgets('enable debugging', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + debuggingEnabled: true, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.debuggingEnabled, true); + }); + + testWidgets('defaults to false', (WidgetTester tester) async { + await tester.pumpWidget(const WebView()); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.debuggingEnabled, false); + }); + + testWidgets('can be changed', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(WebView(key: key)); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + await tester.pumpWidget(WebView( + key: key, + debuggingEnabled: true, + )); + + expect(platformWebView.debuggingEnabled, true); + + await tester.pumpWidget(WebView( + key: key, + debuggingEnabled: false, + )); + + expect(platformWebView.debuggingEnabled, false); + }); + }); + + group('zoomEnabled', () { + testWidgets('Enable zoom', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + zoomEnabled: true, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.zoomEnabled, isTrue); + }); + + testWidgets('defaults to true', (WidgetTester tester) async { + await tester.pumpWidget(const WebView()); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.zoomEnabled, isTrue); + }); + + testWidgets('can be changed', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(WebView(key: key)); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + await tester.pumpWidget(WebView( + key: key, + zoomEnabled: true, + )); + + expect(platformWebView.zoomEnabled, isTrue); + + await tester.pumpWidget(WebView( + key: key, + zoomEnabled: false, + )); + + expect(platformWebView.zoomEnabled, isFalse); + }); + }); + + group('Custom platform implementation', () { + setUpAll(() { + WebView.platform = MyWebViewPlatform(); + }); + tearDownAll(() { + WebView.platform = null; + }); + + testWidgets('creation', (WidgetTester tester) async { + await tester.pumpWidget( + const WebView( + initialUrl: 'https://youtube.com', + gestureNavigationEnabled: true, + ), + ); + + final MyWebViewPlatform builder = WebView.platform as MyWebViewPlatform; + final MyWebViewPlatformController platform = builder.lastPlatformBuilt!; + + expect( + platform.creationParams, + MatchesCreationParams(CreationParams( + initialUrl: 'https://youtube.com', + webSettings: WebSettings( + javascriptMode: JavascriptMode.disabled, + hasNavigationDelegate: false, + debuggingEnabled: false, + userAgent: WebSetting.of(null), + gestureNavigationEnabled: true, + zoomEnabled: true, + ), + ))); + }); + + testWidgets('loadUrl', (WidgetTester tester) async { + late WebViewController controller; + await tester.pumpWidget( + WebView( + initialUrl: 'https://youtube.com', + onWebViewCreated: (WebViewController webViewController) { + controller = webViewController; + }, + ), + ); + + final MyWebViewPlatform builder = WebView.platform as MyWebViewPlatform; + final MyWebViewPlatformController platform = builder.lastPlatformBuilt!; + + final Map headers = { + 'header': 'value', + }; + + await controller.loadUrl('https://google.com', headers: headers); + + expect(platform.lastUrlLoaded, 'https://google.com'); + expect(platform.lastRequestHeaders, headers); + }); + }); + testWidgets('Set UserAgent', (WidgetTester tester) async { + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + javascriptMode: JavascriptMode.unrestricted, + )); + + final FakePlatformWebView platformWebView = + fakePlatformViewsController.lastCreatedView!; + + expect(platformWebView.userAgent, isNull); + + await tester.pumpWidget(const WebView( + initialUrl: 'https://youtube.com', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'UA', + )); + + expect(platformWebView.userAgent, 'UA'); + }); +} + +class FakePlatformWebView { + FakePlatformWebView(int? id, Map params) { + if (params.containsKey('initialUrl')) { + final String? initialUrl = params['initialUrl']; + if (initialUrl != null) { + history.add(initialUrl); + currentPosition++; + } + } + if (params.containsKey('javascriptChannelNames')) { + javascriptChannelNames = + List.from(params['javascriptChannelNames']); + } + javascriptMode = JavascriptMode.values[params['settings']['jsMode']]; + hasNavigationDelegate = + params['settings']['hasNavigationDelegate'] ?? false; + debuggingEnabled = params['settings']['debuggingEnabled']; + userAgent = params['settings']['userAgent']; + zoomEnabled = params['settings']['zoomEnabled'] ?? true; + channel = MethodChannel( + 'plugins.flutter.io/webview_$id', const StandardMethodCodec()); + channel.setMockMethodCallHandler(onMethodCall); + } + + late MethodChannel channel; + + List history = []; + int currentPosition = -1; + int amountOfReloadsOnCurrentUrl = 0; + bool hasCache = true; + + String? get currentUrl => history.isEmpty ? null : history[currentPosition]; + JavascriptMode? javascriptMode; + List? javascriptChannelNames; + + bool? hasNavigationDelegate; + bool? debuggingEnabled; + String? userAgent; + bool? zoomEnabled; + + String? lastRunJavaScriptString; + + Future onMethodCall(MethodCall call) { + switch (call.method) { + case 'loadUrl': + final Map request = call.arguments; + _loadUrl(request['url']); + return Future.sync(() {}); + case 'updateSettings': + if (call.arguments['jsMode'] != null) { + javascriptMode = JavascriptMode.values[call.arguments['jsMode']]; + } + if (call.arguments['hasNavigationDelegate'] != null) { + hasNavigationDelegate = call.arguments['hasNavigationDelegate']; + } + if (call.arguments['debuggingEnabled'] != null) { + debuggingEnabled = call.arguments['debuggingEnabled']; + } + userAgent = call.arguments['userAgent']; + if (call.arguments['zoomEnabled'] != null) { + zoomEnabled = call.arguments['zoomEnabled']; + } + break; + case 'canGoBack': + return Future.sync(() => currentPosition > 0); + case 'canGoForward': + return Future.sync(() => currentPosition < history.length - 1); + case 'goBack': + currentPosition = max(-1, currentPosition - 1); + return Future.sync(() {}); + case 'goForward': + currentPosition = min(history.length - 1, currentPosition + 1); + return Future.sync(() {}); + case 'reload': + amountOfReloadsOnCurrentUrl++; + return Future.sync(() {}); + case 'currentUrl': + return Future.value(currentUrl); + case 'runJavascriptReturningResult': + case 'evaluateJavascript': + lastRunJavaScriptString = call.arguments; + return Future.value(call.arguments); + case 'runJavascript': + lastRunJavaScriptString = call.arguments; + return Future.sync(() {}); + case 'addJavascriptChannels': + final List channelNames = List.from(call.arguments); + javascriptChannelNames!.addAll(channelNames); + break; + case 'removeJavascriptChannels': + final List channelNames = List.from(call.arguments); + javascriptChannelNames! + .removeWhere((String channel) => channelNames.contains(channel)); + break; + case 'clearCache': + hasCache = false; + return Future.sync(() {}); + } + return Future.sync(() {}); + } + + void fakeJavascriptPostMessage(String jsChannel, String message) { + final StandardMethodCodec codec = const StandardMethodCodec(); + final Map arguments = { + 'channel': jsChannel, + 'message': message + }; + final ByteData data = codec + .encodeMethodCall(MethodCall('javascriptChannelMessage', arguments)); + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage(channel.name, data, (ByteData? data) {}); + } + + // Fakes a main frame navigation that was initiated by the webview, e.g when + // the user clicks a link in the currently loaded page. + void fakeNavigate(String url) { + if (!hasNavigationDelegate!) { + print('no navigation delegate'); + _loadUrl(url); + return; + } + final StandardMethodCodec codec = const StandardMethodCodec(); + final Map arguments = { + 'url': url, + 'isForMainFrame': true + }; + final ByteData data = + codec.encodeMethodCall(MethodCall('navigationRequest', arguments)); + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage(channel.name, data, (ByteData? data) { + final bool allow = codec.decodeEnvelope(data!); + if (allow) { + _loadUrl(url); + } + }); + } + + void fakeOnPageStartedCallback() { + final StandardMethodCodec codec = const StandardMethodCodec(); + + final ByteData data = codec.encodeMethodCall(MethodCall( + 'onPageStarted', + {'url': currentUrl}, + )); + + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + data, + (ByteData? data) {}, + ); + } + + void fakeOnPageFinishedCallback() { + final StandardMethodCodec codec = const StandardMethodCodec(); + + final ByteData data = codec.encodeMethodCall(MethodCall( + 'onPageFinished', + {'url': currentUrl}, + )); + + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + data, + (ByteData? data) {}, + ); + } + + void fakeOnProgressCallback(int progress) { + final StandardMethodCodec codec = const StandardMethodCodec(); + + final ByteData data = codec.encodeMethodCall(MethodCall( + 'onProgress', + {'progress': progress}, + )); + + _ambiguate(ServicesBinding.instance)! + .defaultBinaryMessenger + .handlePlatformMessage(channel.name, data, (ByteData? data) {}); + } + + void _loadUrl(String? url) { + history = history.sublist(0, currentPosition + 1); + history.add(url); + currentPosition++; + amountOfReloadsOnCurrentUrl = 0; + } +} + +class _FakePlatformViewsController { + FakePlatformWebView? lastCreatedView; + + Future fakePlatformViewsMethodHandler(MethodCall call) { + switch (call.method) { + case 'create': + final Map args = call.arguments; + final Map params = _decodeParams(args['params'])!; + lastCreatedView = FakePlatformWebView( + args['id'], + params, + ); + return Future.sync(() => 1); + default: + return Future.sync(() {}); + } + } + + void reset() { + lastCreatedView = null; + } +} + +Map? _decodeParams(Uint8List paramsMessage) { + final ByteBuffer buffer = paramsMessage.buffer; + final ByteData messageBytes = buffer.asByteData( + paramsMessage.offsetInBytes, + paramsMessage.lengthInBytes, + ); + return const StandardMessageCodec().decodeMessage(messageBytes); +} + +class _FakeCookieManager { + _FakeCookieManager() { + final MethodChannel channel = const MethodChannel( + 'plugins.flutter.io/cookie_manager', + StandardMethodCodec(), + ); + channel.setMockMethodCallHandler(onMethodCall); + } + + bool hasCookies = true; + + Future onMethodCall(MethodCall call) { + switch (call.method) { + case 'clearCookies': + bool hadCookies = false; + if (hasCookies) { + hadCookies = true; + hasCookies = false; + } + return Future.sync(() { + return hadCookies; + }); + } + return Future.sync(() => true); + } + + void reset() { + hasCookies = true; + } +} + +class MyWebViewPlatform implements WebViewPlatform { + MyWebViewPlatformController? lastPlatformBuilt; + + @override + Widget build({ + BuildContext? context, + CreationParams? creationParams, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + }) { + assert(onWebViewPlatformCreated != null); + lastPlatformBuilt = MyWebViewPlatformController( + creationParams, gestureRecognizers, webViewPlatformCallbacksHandler); + onWebViewPlatformCreated!(lastPlatformBuilt); + return Container(); + } + + @override + Future clearCookies() { + return Future.sync(() => true); + } +} + +class MyWebViewPlatformController extends WebViewPlatformController { + MyWebViewPlatformController(this.creationParams, this.gestureRecognizers, + WebViewPlatformCallbacksHandler platformHandler) + : super(platformHandler); + + CreationParams? creationParams; + Set>? gestureRecognizers; + + String? lastUrlLoaded; + Map? lastRequestHeaders; + + @override + Future loadUrl(String url, Map? headers) async { + equals(1, 1); + lastUrlLoaded = url; + lastRequestHeaders = headers; + } +} + +class MatchesWebSettings extends Matcher { + MatchesWebSettings(this._webSettings); + + final WebSettings? _webSettings; + + @override + Description describe(Description description) => + description.add('$_webSettings'); + + @override + bool matches( + covariant WebSettings webSettings, Map matchState) { + return _webSettings!.javascriptMode == webSettings.javascriptMode && + _webSettings!.hasNavigationDelegate == + webSettings.hasNavigationDelegate && + _webSettings!.debuggingEnabled == webSettings.debuggingEnabled && + _webSettings!.gestureNavigationEnabled == + webSettings.gestureNavigationEnabled && + _webSettings!.userAgent == webSettings.userAgent && + _webSettings!.zoomEnabled == webSettings.zoomEnabled; + } +} + +class MatchesCreationParams extends Matcher { + MatchesCreationParams(this._creationParams); + + final CreationParams _creationParams; + + @override + Description describe(Description description) => + description.add('$_creationParams'); + + @override + bool matches(covariant CreationParams creationParams, + Map matchState) { + return _creationParams.initialUrl == creationParams.initialUrl && + MatchesWebSettings(_creationParams.webSettings) + .matches(creationParams.webSettings!, matchState) && + orderedEquals(_creationParams.javascriptChannelNames) + .matches(creationParams.javascriptChannelNames, matchState); + } +} + +/// This allows a value of type T or T? to be treated as a value of type T?. +/// +/// We use this so that APIs that have become non-nullable can still be used +/// with `!` and `?` on the stable branch. +// TODO(ianh): Remove this once we roll stable in late 2021. +T? _ambiguate(T? value) => value; diff --git a/packages/webview_flutter/webview_flutter_android/AUTHORS b/packages/webview_flutter/webview_flutter_android/AUTHORS new file mode 100644 index 000000000000..22e2b0ef78fc --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/AUTHORS @@ -0,0 +1,69 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom +Nick Bradshaw + diff --git a/packages/webview_flutter/webview_flutter_android/CHANGELOG.md b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md new file mode 100644 index 000000000000..176028f2b11c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/CHANGELOG.md @@ -0,0 +1,20 @@ +## 2.2.0 + +* Implemented new `runJavascript` and `runJavascriptReturningResult` methods in platform interface. + +## 2.1.0 + +* Add `zoomEnabled` functionality. + +## 2.0.15 + +* Added Overrides in FlutterWebView.java + +## 2.0.14 + +* Update example App so navigation menu loads immediatly but only becomes available when `WebViewController` is available (same behavior as example App in webview_flutter package). + +## 2.0.13 + +* Extract Android implementation from `webview_flutter`. + diff --git a/packages/webview_flutter/webview_flutter_android/LICENSE b/packages/webview_flutter/webview_flutter_android/LICENSE new file mode 100644 index 000000000000..77130909e474 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/LICENSE @@ -0,0 +1,26 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/packages/webview_flutter/webview_flutter_android/README.md b/packages/webview_flutter/webview_flutter_android/README.md new file mode 100644 index 000000000000..38838562d13c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/README.md @@ -0,0 +1,12 @@ +# webview\_flutter\_android + +The Android implementation of [`webview_flutter`][1]. + +## Usage + +This package is [endorsed][2], which means you can simply use `webview_flutter` +normally. This package will be automatically included in your app when you do. + +[1]: https://pub.dev/packages/webview_flutter +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin + diff --git a/packages/webview_flutter/webview_flutter_android/android/build.gradle b/packages/webview_flutter/webview_flutter_android/android/build.gradle new file mode 100644 index 000000000000..bb9fe9708eaa --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/build.gradle @@ -0,0 +1,65 @@ +group 'io.flutter.plugins.webviewflutter' +version '1.0-SNAPSHOT' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 29 + + defaultConfig { + minSdkVersion 19 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + lintOptions { + disable 'InvalidPackage' + disable 'GradleDependency' + } + + dependencies { + implementation 'androidx.annotation:annotation:1.0.0' + implementation 'androidx.webkit:webkit:1.0.0' + testImplementation 'junit:junit:4.12' + testImplementation 'org.mockito:mockito-inline:3.11.1' + testImplementation 'androidx.test:core:1.3.0' + } + + 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 + } + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/settings.gradle b/packages/webview_flutter/webview_flutter_android/android/settings.gradle new file mode 100644 index 000000000000..5be7a4b4c692 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'webview_flutter' diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter_android/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..a087f2c75c24 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java new file mode 100644 index 000000000000..31e3fe08c057 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/DisplayListenerProxy.java @@ -0,0 +1,147 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static android.hardware.display.DisplayManager.DisplayListener; + +import android.annotation.TargetApi; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.util.Log; +import java.lang.reflect.Field; +import java.util.ArrayList; + +/** + * Works around an Android WebView bug by filtering some DisplayListener invocations. + * + *

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

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

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

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

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

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

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

Important: This method is visible for testing purposes only and should + * never be called from outside this class. + * + * @param webViewBuilder a {@link WebViewBuilder} which is responsible for building the {@link + * WebView}. + * @param params creation parameters received over the method channel. + * @param webChromeClient an implementation of WebChromeClient This value may be null. + * @return The new {@link android.webkit.WebView} object. + */ + @VisibleForTesting + static WebView createWebView( + WebViewBuilder webViewBuilder, + Map params, + WebChromeClient webChromeClient, + @Nullable DownloadListener downloadListener) { + boolean usesHybridComposition = Boolean.TRUE.equals(params.get("usesHybridComposition")); + webViewBuilder + .setUsesHybridComposition(usesHybridComposition) + .setDomStorageEnabled(true) // Always enable DOM storage API. + .setJavaScriptCanOpenWindowsAutomatically( + true) // Always allow automatically opening of windows. + .setSupportMultipleWindows(true) // Always support multiple windows. + .setWebChromeClient( + webChromeClient) // Always use {@link FlutterWebChromeClient} as web Chrome client. + .setDownloadListener(downloadListener) + .setZoomControlsEnabled(true); // Always use built-in zoom mechanisms. + + return webViewBuilder.build(); + } + + @Override + public View getView() { + return webView; + } + + @Override + public void onInputConnectionUnlocked() { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).unlockInputConnection(); + } + } + + @Override + public void onInputConnectionLocked() { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).lockInputConnection(); + } + } + + @Override + public void onFlutterViewAttached(View flutterView) { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).setContainerView(flutterView); + } + } + + @Override + public void onFlutterViewDetached() { + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).setContainerView(null); + } + } + + @Override + public void onMethodCall(MethodCall methodCall, Result result) { + switch (methodCall.method) { + case "loadUrl": + loadUrl(methodCall, result); + break; + case "loadAssetHtmlFile": + loadAssetHtmlFile(methodCall, result); + break; + case "loadLocalHtmlFile": + loadLocalHtmlFile(methodCall, result); + break; + case "updateSettings": + updateSettings(methodCall, result); + break; + case "canGoBack": + canGoBack(result); + break; + case "canGoForward": + canGoForward(result); + break; + case "goBack": + goBack(result); + break; + case "goForward": + goForward(result); + break; + case "reload": + reload(result); + break; + case "currentUrl": + currentUrl(result); + break; + case "evaluateJavascript": + case "runJavascriptReturningResult": + evaluateJavaScript(methodCall, result, true); + break; + case "runJavascript": + evaluateJavaScript(methodCall, result, false); + break; + case "addJavascriptChannels": + addJavaScriptChannels(methodCall, result); + break; + case "removeJavascriptChannels": + removeJavaScriptChannels(methodCall, result); + break; + case "clearCache": + clearCache(result); + break; + case "getTitle": + getTitle(result); + break; + case "scrollTo": + scrollTo(methodCall, result); + break; + case "scrollBy": + scrollBy(methodCall, result); + break; + case "getScrollX": + getScrollX(result); + break; + case "getScrollY": + getScrollY(result); + break; + default: + result.notImplemented(); + } + } + + @SuppressWarnings("unchecked") + private void loadUrl(MethodCall methodCall, Result result) { + Map request = (Map) methodCall.arguments; + String url = (String) request.get("url"); + Map headers = (Map) request.get("headers"); + if (headers == null) { + headers = Collections.emptyMap(); + } + webView.loadUrl(url, headers); + result.success(null); + } + + private void loadAssetHtmlFile(MethodCall methodCall, Result result) { + String url = (String) methodCall.arguments; + webView.loadUrl("file:///android_asset/flutter_assets/" + url); + result.success(null); + } + + private void loadLocalHtmlFile(MethodCall methodCall, Result result) { + String url = (String) methodCall.arguments; + webView.loadUrl("file:///" + url); + result.success(null); + } + + private void canGoBack(Result result) { + result.success(webView.canGoBack()); + } + + private void canGoForward(Result result) { + result.success(webView.canGoForward()); + } + + private void goBack(Result result) { + if (webView.canGoBack()) { + webView.goBack(); + } + result.success(null); + } + + private void goForward(Result result) { + if (webView.canGoForward()) { + webView.goForward(); + } + result.success(null); + } + + private void reload(Result result) { + webView.reload(); + result.success(null); + } + + private void currentUrl(Result result) { + result.success(webView.getUrl()); + } + + @SuppressWarnings("unchecked") + private void updateSettings(MethodCall methodCall, Result result) { + applySettings((Map) methodCall.arguments); + result.success(null); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + private void evaluateJavaScript( + MethodCall methodCall, final Result result, final boolean returnValue) { + String jsString = (String) methodCall.arguments; + if (jsString == null) { + throw new UnsupportedOperationException("JavaScript string cannot be null"); + } + webView.evaluateJavascript( + jsString, + new android.webkit.ValueCallback() { + @Override + public void onReceiveValue(String value) { + if (returnValue) { + result.success(value); + } else { + result.success(null); + } + } + }); + } + + @SuppressWarnings("unchecked") + private void addJavaScriptChannels(MethodCall methodCall, Result result) { + List channelNames = (List) methodCall.arguments; + registerJavaScriptChannelNames(channelNames); + result.success(null); + } + + @SuppressWarnings("unchecked") + private void removeJavaScriptChannels(MethodCall methodCall, Result result) { + List channelNames = (List) methodCall.arguments; + for (String channelName : channelNames) { + webView.removeJavascriptInterface(channelName); + } + result.success(null); + } + + private void clearCache(Result result) { + webView.clearCache(true); + WebStorage.getInstance().deleteAllData(); + result.success(null); + } + + private void getTitle(Result result) { + result.success(webView.getTitle()); + } + + private void scrollTo(MethodCall methodCall, Result result) { + Map request = methodCall.arguments(); + int x = (int) request.get("x"); + int y = (int) request.get("y"); + + webView.scrollTo(x, y); + + result.success(null); + } + + private void scrollBy(MethodCall methodCall, Result result) { + Map request = methodCall.arguments(); + int x = (int) request.get("x"); + int y = (int) request.get("y"); + + webView.scrollBy(x, y); + result.success(null); + } + + private void getScrollX(Result result) { + result.success(webView.getScrollX()); + } + + private void getScrollY(Result result) { + result.success(webView.getScrollY()); + } + + private void applySettings(Map settings) { + for (String key : settings.keySet()) { + switch (key) { + case "jsMode": + Integer mode = (Integer) settings.get(key); + if (mode != null) { + updateJsMode(mode); + } + break; + case "hasNavigationDelegate": + final boolean hasNavigationDelegate = (boolean) settings.get(key); + + final WebViewClient webViewClient = + flutterWebViewClient.createWebViewClient(hasNavigationDelegate); + + webView.setWebViewClient(webViewClient); + break; + case "debuggingEnabled": + final boolean debuggingEnabled = (boolean) settings.get(key); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + webView.setWebContentsDebuggingEnabled(debuggingEnabled); + } + break; + case "hasProgressTracking": + flutterWebViewClient.hasProgressTracking = (boolean) settings.get(key); + break; + case "gestureNavigationEnabled": + break; + case "userAgent": + updateUserAgent((String) settings.get(key)); + break; + case "allowsInlineMediaPlayback": + // no-op inline media playback is always allowed on Android. + break; + case "zoomEnabled": + setZoomEnabled((boolean) settings.get(key)); + break; + default: + throw new IllegalArgumentException("Unknown WebView setting: " + key); + } + } + } + + private void updateJsMode(int mode) { + switch (mode) { + case 0: // disabled + webView.getSettings().setJavaScriptEnabled(false); + break; + case 1: // unrestricted + webView.getSettings().setJavaScriptEnabled(true); + break; + default: + throw new IllegalArgumentException("Trying to set unknown JavaScript mode: " + mode); + } + } + + private void updateAutoMediaPlaybackPolicy(int mode) { + // This is the index of the AutoMediaPlaybackPolicy enum, index 1 is always_allow, for all + // other values we require a user gesture. + boolean requireUserGesture = mode != 1; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + webView.getSettings().setMediaPlaybackRequiresUserGesture(requireUserGesture); + } + } + + private void registerJavaScriptChannelNames(List channelNames) { + for (String channelName : channelNames) { + webView.addJavascriptInterface( + new JavaScriptChannel(methodChannel, channelName, platformThreadHandler), channelName); + } + } + + private void updateUserAgent(String userAgent) { + webView.getSettings().setUserAgentString(userAgent); + } + + private void setZoomEnabled(boolean shouldEnable) { + webView.getSettings().setSupportZoom(shouldEnable); + } + + @Override + public void dispose() { + methodChannel.setMethodCallHandler(null); + if (webView instanceof InputAwareWebView) { + ((InputAwareWebView) webView).dispose(); + } + webView.destroy(); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java new file mode 100644 index 000000000000..a86d3e8a4b63 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewClient.java @@ -0,0 +1,323 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.graphics.Bitmap; +import android.os.Build; +import android.util.Log; +import android.view.KeyEvent; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.webkit.WebResourceErrorCompat; +import androidx.webkit.WebViewClientCompat; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +// We need to use WebViewClientCompat to get +// shouldOverrideUrlLoading(WebView view, WebResourceRequest request) +// invoked by the webview on older Android devices, without it pages that use iframes will +// be broken when a navigationDelegate is set on Android version earlier than N. +class FlutterWebViewClient { + private static final String TAG = "FlutterWebViewClient"; + private final MethodChannel methodChannel; + private boolean hasNavigationDelegate; + boolean hasProgressTracking; + + FlutterWebViewClient(MethodChannel methodChannel) { + this.methodChannel = methodChannel; + } + + static String errorCodeToString(int errorCode) { + switch (errorCode) { + case WebViewClient.ERROR_AUTHENTICATION: + return "authentication"; + case WebViewClient.ERROR_BAD_URL: + return "badUrl"; + case WebViewClient.ERROR_CONNECT: + return "connect"; + case WebViewClient.ERROR_FAILED_SSL_HANDSHAKE: + return "failedSslHandshake"; + case WebViewClient.ERROR_FILE: + return "file"; + case WebViewClient.ERROR_FILE_NOT_FOUND: + return "fileNotFound"; + case WebViewClient.ERROR_HOST_LOOKUP: + return "hostLookup"; + case WebViewClient.ERROR_IO: + return "io"; + case WebViewClient.ERROR_PROXY_AUTHENTICATION: + return "proxyAuthentication"; + case WebViewClient.ERROR_REDIRECT_LOOP: + return "redirectLoop"; + case WebViewClient.ERROR_TIMEOUT: + return "timeout"; + case WebViewClient.ERROR_TOO_MANY_REQUESTS: + return "tooManyRequests"; + case WebViewClient.ERROR_UNKNOWN: + return "unknown"; + case WebViewClient.ERROR_UNSAFE_RESOURCE: + return "unsafeResource"; + case WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME: + return "unsupportedAuthScheme"; + case WebViewClient.ERROR_UNSUPPORTED_SCHEME: + return "unsupportedScheme"; + } + + final String message = + String.format(Locale.getDefault(), "Could not find a string for errorCode: %d", errorCode); + throw new IllegalArgumentException(message); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + if (!hasNavigationDelegate) { + return false; + } + notifyOnNavigationRequest( + request.getUrl().toString(), request.getRequestHeaders(), view, request.isForMainFrame()); + // We must make a synchronous decision here whether to allow the navigation or not, + // if the Dart code has set a navigation delegate we want that delegate to decide whether + // to navigate or not, and as we cannot get a response from the Dart delegate synchronously we + // return true here to block the navigation, if the Dart delegate decides to allow the + // navigation the plugin will later make an addition loadUrl call for this url. + // + // Since we cannot call loadUrl for a subframe, we currently only allow the delegate to stop + // navigations that target the main frame, if the request is not for the main frame + // we just return false to allow the navigation. + // + // For more details see: https://github.com/flutter/flutter/issues/25329#issuecomment-464863209 + return request.isForMainFrame(); + } + + boolean shouldOverrideUrlLoading(WebView view, String url) { + if (!hasNavigationDelegate) { + return false; + } + // This version of shouldOverrideUrlLoading is only invoked by the webview on devices with + // webview versions earlier than 67(it is also invoked when hasNavigationDelegate is false). + // On these devices we cannot tell whether the navigation is targeted to the main frame or not. + // We proceed assuming that the navigation is targeted to the main frame. If the page had any + // frames they will be loaded in the main frame instead. + Log.w( + TAG, + "Using a navigationDelegate with an old webview implementation, pages with frames or iframes will not work"); + notifyOnNavigationRequest(url, null, view, true); + return true; + } + + /** + * Notifies the Flutter code that a download should start when a navigation delegate is set. + * + * @param view the webView the result of the navigation delegate will be send to. + * @param url the download url + * @return A boolean whether or not the request is forwarded to the Flutter code. + */ + boolean notifyDownload(WebView view, String url) { + if (!hasNavigationDelegate) { + return false; + } + + notifyOnNavigationRequest(url, null, view, true); + return true; + } + + private void onPageStarted(WebView view, String url) { + Map args = new HashMap<>(); + args.put("url", url); + methodChannel.invokeMethod("onPageStarted", args); + } + + private void onPageFinished(WebView view, String url) { + Map args = new HashMap<>(); + args.put("url", url); + methodChannel.invokeMethod("onPageFinished", args); + } + + void onLoadingProgress(int progress) { + if (hasProgressTracking) { + Map args = new HashMap<>(); + args.put("progress", progress); + methodChannel.invokeMethod("onProgress", args); + } + } + + private void onWebResourceError( + final int errorCode, final String description, final String failingUrl) { + final Map args = new HashMap<>(); + args.put("errorCode", errorCode); + args.put("description", description); + args.put("errorType", FlutterWebViewClient.errorCodeToString(errorCode)); + args.put("failingUrl", failingUrl); + methodChannel.invokeMethod("onWebResourceError", args); + } + + private void notifyOnNavigationRequest( + String url, Map headers, WebView webview, boolean isMainFrame) { + HashMap args = new HashMap<>(); + args.put("url", url); + args.put("isForMainFrame", isMainFrame); + if (isMainFrame) { + methodChannel.invokeMethod( + "navigationRequest", args, new OnNavigationRequestResult(url, headers, webview)); + } else { + methodChannel.invokeMethod("navigationRequest", args); + } + } + + // This method attempts to avoid using WebViewClientCompat due to bug + // https://bugs.chromium.org/p/chromium/issues/detail?id=925887. Also, see + // https://github.com/flutter/flutter/issues/29446. + WebViewClient createWebViewClient(boolean hasNavigationDelegate) { + this.hasNavigationDelegate = hasNavigationDelegate; + + if (!hasNavigationDelegate || android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return internalCreateWebViewClient(); + } + + return internalCreateWebViewClientCompat(); + } + + private WebViewClient internalCreateWebViewClient() { + return new WebViewClient() { + @TargetApi(Build.VERSION_CODES.N) + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, request); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + FlutterWebViewClient.this.onPageStarted(view, url); + } + + @Override + public void onPageFinished(WebView view, String url) { + FlutterWebViewClient.this.onPageFinished(view, url); + } + + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onReceivedError( + WebView view, WebResourceRequest request, WebResourceError error) { + if (request.isForMainFrame()) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + } + } + + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onWebResourceError(errorCode, description, failingUrl); + } + + @Override + public void onUnhandledKeyEvent(WebView view, KeyEvent event) { + // Deliberately empty. Occasionally the webview will mark events as having failed to be + // handled even though they were handled. We don't want to propagate those as they're not + // truly lost. + } + }; + } + + private WebViewClientCompat internalCreateWebViewClientCompat() { + return new WebViewClientCompat() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, request); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + return FlutterWebViewClient.this.shouldOverrideUrlLoading(view, url); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + FlutterWebViewClient.this.onPageStarted(view, url); + } + + @Override + public void onPageFinished(WebView view, String url) { + FlutterWebViewClient.this.onPageFinished(view, url); + } + + // This method is only called when the WebViewFeature.RECEIVE_WEB_RESOURCE_ERROR feature is + // enabled. The deprecated method is called when a device doesn't support this. + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @SuppressLint("RequiresFeature") + @Override + public void onReceivedError( + @NonNull WebView view, + @NonNull WebResourceRequest request, + @NonNull WebResourceErrorCompat error) { + if (request.isForMainFrame()) { + FlutterWebViewClient.this.onWebResourceError( + error.getErrorCode(), error.getDescription().toString(), request.getUrl().toString()); + } + } + + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + FlutterWebViewClient.this.onWebResourceError(errorCode, description, failingUrl); + } + + @Override + public void onUnhandledKeyEvent(WebView view, KeyEvent event) { + // Deliberately empty. Occasionally the webview will mark events as having failed to be + // handled even though they were handled. We don't want to propagate those as they're not + // truly lost. + } + }; + } + + private static class OnNavigationRequestResult implements MethodChannel.Result { + private final String url; + private final Map headers; + private final WebView webView; + + private OnNavigationRequestResult(String url, Map headers, WebView webView) { + this.url = url; + this.headers = headers; + this.webView = webView; + } + + @Override + public void success(Object shouldLoad) { + Boolean typedShouldLoad = (Boolean) shouldLoad; + if (typedShouldLoad) { + loadUrl(); + } + } + + @Override + public void error(String errorCode, String s1, Object o) { + throw new IllegalStateException("navigationRequest calls must succeed"); + } + + @Override + public void notImplemented() { + throw new IllegalStateException( + "navigationRequest must be implemented by the webview method channel"); + } + + private void loadUrl() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + webView.loadUrl(url, headers); + } else { + webView.loadUrl(url); + } + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java new file mode 100644 index 000000000000..8fe58104a0fb --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebViewFactory.java @@ -0,0 +1,33 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.content.Context; +import android.view.View; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.StandardMessageCodec; +import io.flutter.plugin.platform.PlatformView; +import io.flutter.plugin.platform.PlatformViewFactory; +import java.util.Map; + +public final class FlutterWebViewFactory extends PlatformViewFactory { + private final BinaryMessenger messenger; + private final View containerView; + + FlutterWebViewFactory(BinaryMessenger messenger, View containerView) { + super(StandardMessageCodec.INSTANCE); + this.messenger = messenger; + this.containerView = containerView; + } + + @SuppressWarnings("unchecked") + @Override + public PlatformView create(Context context, int id, Object args) { + Map params = (Map) args; + MethodChannel methodChannel = new MethodChannel(messenger, "plugins.flutter.io/webview_" + id); + return new FlutterWebView(context, methodChannel, params, containerView); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/GeneratedAndroidWebView.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/GeneratedAndroidWebView.java new file mode 100644 index 000000000000..ba2b9b1ac481 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/GeneratedAndroidWebView.java @@ -0,0 +1,1909 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Autogenerated from Pigeon (v1.0.7), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package io.flutter.plugins.webviewflutter; + +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MessageCodec; +import io.flutter.plugin.common.StandardMessageCodec; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** Generated class from Pigeon. */ +@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"}) +public class GeneratedAndroidWebView { + + /** Generated class from Pigeon that represents data sent in messages. */ + public static class WebResourceRequestData { + private String url; + + public String getUrl() { + return url; + } + + public void setUrl(String setterArg) { + this.url = setterArg; + } + + private Boolean isForMainFrame; + + public Boolean getIsForMainFrame() { + return isForMainFrame; + } + + public void setIsForMainFrame(Boolean setterArg) { + this.isForMainFrame = setterArg; + } + + private Boolean isRedirect; + + public Boolean getIsRedirect() { + return isRedirect; + } + + public void setIsRedirect(Boolean setterArg) { + this.isRedirect = setterArg; + } + + private Boolean hasGesture; + + public Boolean getHasGesture() { + return hasGesture; + } + + public void setHasGesture(Boolean setterArg) { + this.hasGesture = setterArg; + } + + private String method; + + public String getMethod() { + return method; + } + + public void setMethod(String setterArg) { + this.method = setterArg; + } + + private Map requestHeaders; + + public Map getRequestHeaders() { + return requestHeaders; + } + + public void setRequestHeaders(Map setterArg) { + this.requestHeaders = setterArg; + } + + Map toMap() { + Map toMapResult = new HashMap<>(); + toMapResult.put("url", url); + toMapResult.put("isForMainFrame", isForMainFrame); + toMapResult.put("isRedirect", isRedirect); + toMapResult.put("hasGesture", hasGesture); + toMapResult.put("method", method); + toMapResult.put("requestHeaders", requestHeaders); + return toMapResult; + } + + static WebResourceRequestData fromMap(Map map) { + WebResourceRequestData fromMapResult = new WebResourceRequestData(); + Object url = map.get("url"); + fromMapResult.url = (String) url; + Object isForMainFrame = map.get("isForMainFrame"); + fromMapResult.isForMainFrame = (Boolean) isForMainFrame; + Object isRedirect = map.get("isRedirect"); + fromMapResult.isRedirect = (Boolean) isRedirect; + Object hasGesture = map.get("hasGesture"); + fromMapResult.hasGesture = (Boolean) hasGesture; + Object method = map.get("method"); + fromMapResult.method = (String) method; + Object requestHeaders = map.get("requestHeaders"); + fromMapResult.requestHeaders = (Map) requestHeaders; + return fromMapResult; + } + } + + /** Generated class from Pigeon that represents data sent in messages. */ + public static class WebResourceErrorData { + private Long errorCode; + + public Long getErrorCode() { + return errorCode; + } + + public void setErrorCode(Long setterArg) { + this.errorCode = setterArg; + } + + private String description; + + public String getDescription() { + return description; + } + + public void setDescription(String setterArg) { + this.description = setterArg; + } + + Map toMap() { + Map toMapResult = new HashMap<>(); + toMapResult.put("errorCode", errorCode); + toMapResult.put("description", description); + return toMapResult; + } + + static WebResourceErrorData fromMap(Map map) { + WebResourceErrorData fromMapResult = new WebResourceErrorData(); + Object errorCode = map.get("errorCode"); + fromMapResult.errorCode = + (errorCode == null) + ? null + : ((errorCode instanceof Integer) ? (Integer) errorCode : (Long) errorCode); + Object description = map.get("description"); + fromMapResult.description = (String) description; + return fromMapResult; + } + } + + public interface Result { + void success(T result); + + void error(Throwable error); + } + + private static class WebViewHostApiCodec extends StandardMessageCodec { + public static final WebViewHostApiCodec INSTANCE = new WebViewHostApiCodec(); + + private WebViewHostApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface WebViewHostApi { + void create(Long instanceId, Boolean useHybridComposition); + + void dispose(Long instanceId); + + void loadUrl(Long instanceId, String url, Map headers); + + String getUrl(Long instanceId); + + Boolean canGoBack(Long instanceId); + + Boolean canGoForward(Long instanceId); + + void goBack(Long instanceId); + + void goForward(Long instanceId); + + void reload(Long instanceId); + + void clearCache(Long instanceId, Boolean includeDiskFiles); + + void evaluateJavascript(Long instanceId, String javascriptString, Result result); + + String getTitle(Long instanceId); + + void scrollTo(Long instanceId, Long x, Long y); + + void scrollBy(Long instanceId, Long x, Long y); + + Long getScrollX(Long instanceId); + + Long getScrollY(Long instanceId); + + void setWebContentsDebuggingEnabled(Boolean enabled); + + void setWebViewClient(Long instanceId, Long webViewClientInstanceId); + + void addJavaScriptChannel(Long instanceId, Long javaScriptChannelInstanceId); + + void removeJavaScriptChannel(Long instanceId, Long javaScriptChannelInstanceId); + + void setDownloadListener(Long instanceId, Long listenerInstanceId); + + void setWebChromeClient(Long instanceId, Long clientInstanceId); + + /** The codec used by WebViewHostApi. */ + static MessageCodec getCodec() { + return WebViewHostApiCodec.INSTANCE; + } + + /** Sets up an instance of `WebViewHostApi` to handle messages through the `binaryMessenger`. */ + static void setup(BinaryMessenger binaryMessenger, WebViewHostApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.create", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean useHybridCompositionArg = (Boolean) args.get(1); + if (useHybridCompositionArg == null) { + throw new NullPointerException("useHybridCompositionArg unexpectedly null."); + } + api.create(instanceIdArg.longValue(), useHybridCompositionArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.dispose", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.dispose(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.loadUrl", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + String urlArg = (String) args.get(1); + if (urlArg == null) { + throw new NullPointerException("urlArg unexpectedly null."); + } + Map headersArg = (Map) args.get(2); + if (headersArg == null) { + throw new NullPointerException("headersArg unexpectedly null."); + } + api.loadUrl(instanceIdArg.longValue(), urlArg, headersArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.getUrl", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + String output = api.getUrl(instanceIdArg.longValue()); + wrapped.put("result", output); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.canGoBack", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean output = api.canGoBack(instanceIdArg.longValue()); + wrapped.put("result", output); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.canGoForward", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean output = api.canGoForward(instanceIdArg.longValue()); + wrapped.put("result", output); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.goBack", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.goBack(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.goForward", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.goForward(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.reload", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.reload(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.clearCache", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean includeDiskFilesArg = (Boolean) args.get(1); + if (includeDiskFilesArg == null) { + throw new NullPointerException("includeDiskFilesArg unexpectedly null."); + } + api.clearCache(instanceIdArg.longValue(), includeDiskFilesArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewHostApi.evaluateJavascript", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + String javascriptStringArg = (String) args.get(1); + if (javascriptStringArg == null) { + throw new NullPointerException("javascriptStringArg unexpectedly null."); + } + Result resultCallback = + new Result() { + public void success(String result) { + wrapped.put("result", result); + reply.reply(wrapped); + } + + public void error(Throwable error) { + wrapped.put("error", wrapError(error)); + reply.reply(wrapped); + } + }; + + api.evaluateJavascript( + instanceIdArg.longValue(), javascriptStringArg, resultCallback); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.getTitle", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + String output = api.getTitle(instanceIdArg.longValue()); + wrapped.put("result", output); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.scrollTo", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number xArg = (Number) args.get(1); + if (xArg == null) { + throw new NullPointerException("xArg unexpectedly null."); + } + Number yArg = (Number) args.get(2); + if (yArg == null) { + throw new NullPointerException("yArg unexpectedly null."); + } + api.scrollTo(instanceIdArg.longValue(), xArg.longValue(), yArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.scrollBy", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number xArg = (Number) args.get(1); + if (xArg == null) { + throw new NullPointerException("xArg unexpectedly null."); + } + Number yArg = (Number) args.get(2); + if (yArg == null) { + throw new NullPointerException("yArg unexpectedly null."); + } + api.scrollBy(instanceIdArg.longValue(), xArg.longValue(), yArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.getScrollX", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Long output = api.getScrollX(instanceIdArg.longValue()); + wrapped.put("result", output); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.getScrollY", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Long output = api.getScrollY(instanceIdArg.longValue()); + wrapped.put("result", output); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewHostApi.setWebContentsDebuggingEnabled", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Boolean enabledArg = (Boolean) args.get(0); + if (enabledArg == null) { + throw new NullPointerException("enabledArg unexpectedly null."); + } + api.setWebContentsDebuggingEnabled(enabledArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewHostApi.setWebViewClient", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number webViewClientInstanceIdArg = (Number) args.get(1); + if (webViewClientInstanceIdArg == null) { + throw new NullPointerException("webViewClientInstanceIdArg unexpectedly null."); + } + api.setWebViewClient( + instanceIdArg.longValue(), webViewClientInstanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewHostApi.addJavaScriptChannel", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number javaScriptChannelInstanceIdArg = (Number) args.get(1); + if (javaScriptChannelInstanceIdArg == null) { + throw new NullPointerException( + "javaScriptChannelInstanceIdArg unexpectedly null."); + } + api.addJavaScriptChannel( + instanceIdArg.longValue(), javaScriptChannelInstanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewHostApi.removeJavaScriptChannel", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number javaScriptChannelInstanceIdArg = (Number) args.get(1); + if (javaScriptChannelInstanceIdArg == null) { + throw new NullPointerException( + "javaScriptChannelInstanceIdArg unexpectedly null."); + } + api.removeJavaScriptChannel( + instanceIdArg.longValue(), javaScriptChannelInstanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewHostApi.setDownloadListener", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number listenerInstanceIdArg = (Number) args.get(1); + if (listenerInstanceIdArg == null) { + throw new NullPointerException("listenerInstanceIdArg unexpectedly null."); + } + api.setDownloadListener( + instanceIdArg.longValue(), listenerInstanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewHostApi.setWebChromeClient", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number clientInstanceIdArg = (Number) args.get(1); + if (clientInstanceIdArg == null) { + throw new NullPointerException("clientInstanceIdArg unexpectedly null."); + } + api.setWebChromeClient( + instanceIdArg.longValue(), clientInstanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + + private static class WebSettingsHostApiCodec extends StandardMessageCodec { + public static final WebSettingsHostApiCodec INSTANCE = new WebSettingsHostApiCodec(); + + private WebSettingsHostApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface WebSettingsHostApi { + void create(Long instanceId, Long webViewInstanceId); + + void dispose(Long instanceId); + + void setDomStorageEnabled(Long instanceId, Boolean flag); + + void setJavaScriptCanOpenWindowsAutomatically(Long instanceId, Boolean flag); + + void setSupportMultipleWindows(Long instanceId, Boolean support); + + void setJavaScriptEnabled(Long instanceId, Boolean flag); + + void setUserAgentString(Long instanceId, String userAgentString); + + void setMediaPlaybackRequiresUserGesture(Long instanceId, Boolean require); + + void setSupportZoom(Long instanceId, Boolean support); + + void setLoadWithOverviewMode(Long instanceId, Boolean overview); + + void setUseWideViewPort(Long instanceId, Boolean use); + + void setDisplayZoomControls(Long instanceId, Boolean enabled); + + void setBuiltInZoomControls(Long instanceId, Boolean enabled); + + /** The codec used by WebSettingsHostApi. */ + static MessageCodec getCodec() { + return WebSettingsHostApiCodec.INSTANCE; + } + + /** + * Sets up an instance of `WebSettingsHostApi` to handle messages through the `binaryMessenger`. + */ + static void setup(BinaryMessenger binaryMessenger, WebSettingsHostApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebSettingsHostApi.create", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number webViewInstanceIdArg = (Number) args.get(1); + if (webViewInstanceIdArg == null) { + throw new NullPointerException("webViewInstanceIdArg unexpectedly null."); + } + api.create(instanceIdArg.longValue(), webViewInstanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebSettingsHostApi.dispose", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.dispose(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setDomStorageEnabled", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean flagArg = (Boolean) args.get(1); + if (flagArg == null) { + throw new NullPointerException("flagArg unexpectedly null."); + } + api.setDomStorageEnabled(instanceIdArg.longValue(), flagArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptCanOpenWindowsAutomatically", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean flagArg = (Boolean) args.get(1); + if (flagArg == null) { + throw new NullPointerException("flagArg unexpectedly null."); + } + api.setJavaScriptCanOpenWindowsAutomatically(instanceIdArg.longValue(), flagArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setSupportMultipleWindows", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean supportArg = (Boolean) args.get(1); + if (supportArg == null) { + throw new NullPointerException("supportArg unexpectedly null."); + } + api.setSupportMultipleWindows(instanceIdArg.longValue(), supportArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptEnabled", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean flagArg = (Boolean) args.get(1); + if (flagArg == null) { + throw new NullPointerException("flagArg unexpectedly null."); + } + api.setJavaScriptEnabled(instanceIdArg.longValue(), flagArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setUserAgentString", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + String userAgentStringArg = (String) args.get(1); + if (userAgentStringArg == null) { + throw new NullPointerException("userAgentStringArg unexpectedly null."); + } + api.setUserAgentString(instanceIdArg.longValue(), userAgentStringArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setMediaPlaybackRequiresUserGesture", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean requireArg = (Boolean) args.get(1); + if (requireArg == null) { + throw new NullPointerException("requireArg unexpectedly null."); + } + api.setMediaPlaybackRequiresUserGesture(instanceIdArg.longValue(), requireArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setSupportZoom", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean supportArg = (Boolean) args.get(1); + if (supportArg == null) { + throw new NullPointerException("supportArg unexpectedly null."); + } + api.setSupportZoom(instanceIdArg.longValue(), supportArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setLoadWithOverviewMode", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean overviewArg = (Boolean) args.get(1); + if (overviewArg == null) { + throw new NullPointerException("overviewArg unexpectedly null."); + } + api.setLoadWithOverviewMode(instanceIdArg.longValue(), overviewArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setUseWideViewPort", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean useArg = (Boolean) args.get(1); + if (useArg == null) { + throw new NullPointerException("useArg unexpectedly null."); + } + api.setUseWideViewPort(instanceIdArg.longValue(), useArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setDisplayZoomControls", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean enabledArg = (Boolean) args.get(1); + if (enabledArg == null) { + throw new NullPointerException("enabledArg unexpectedly null."); + } + api.setDisplayZoomControls(instanceIdArg.longValue(), enabledArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebSettingsHostApi.setBuiltInZoomControls", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean enabledArg = (Boolean) args.get(1); + if (enabledArg == null) { + throw new NullPointerException("enabledArg unexpectedly null."); + } + api.setBuiltInZoomControls(instanceIdArg.longValue(), enabledArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + + private static class JavaScriptChannelHostApiCodec extends StandardMessageCodec { + public static final JavaScriptChannelHostApiCodec INSTANCE = + new JavaScriptChannelHostApiCodec(); + + private JavaScriptChannelHostApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface JavaScriptChannelHostApi { + void create(Long instanceId, String channelName); + + void dispose(Long instanceId); + + /** The codec used by JavaScriptChannelHostApi. */ + static MessageCodec getCodec() { + return JavaScriptChannelHostApiCodec.INSTANCE; + } + + /** + * Sets up an instance of `JavaScriptChannelHostApi` to handle messages through the + * `binaryMessenger`. + */ + static void setup(BinaryMessenger binaryMessenger, JavaScriptChannelHostApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.JavaScriptChannelHostApi.create", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + String channelNameArg = (String) args.get(1); + if (channelNameArg == null) { + throw new NullPointerException("channelNameArg unexpectedly null."); + } + api.create(instanceIdArg.longValue(), channelNameArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.JavaScriptChannelHostApi.dispose", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.dispose(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + + private static class JavaScriptChannelFlutterApiCodec extends StandardMessageCodec { + public static final JavaScriptChannelFlutterApiCodec INSTANCE = + new JavaScriptChannelFlutterApiCodec(); + + private JavaScriptChannelFlutterApiCodec() {} + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java. */ + public static class JavaScriptChannelFlutterApi { + private final BinaryMessenger binaryMessenger; + + public JavaScriptChannelFlutterApi(BinaryMessenger argBinaryMessenger) { + this.binaryMessenger = argBinaryMessenger; + } + + public interface Reply { + void reply(T reply); + } + + static MessageCodec getCodec() { + return JavaScriptChannelFlutterApiCodec.INSTANCE; + } + + public void postMessage(Long instanceIdArg, String messageArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.JavaScriptChannelFlutterApi.postMessage", + getCodec()); + channel.send( + new ArrayList(Arrays.asList(instanceIdArg, messageArg)), + channelReply -> { + callback.reply(null); + }); + } + } + + private static class WebViewClientHostApiCodec extends StandardMessageCodec { + public static final WebViewClientHostApiCodec INSTANCE = new WebViewClientHostApiCodec(); + + private WebViewClientHostApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface WebViewClientHostApi { + void create(Long instanceId, Boolean shouldOverrideUrlLoading); + + void dispose(Long instanceId); + + /** The codec used by WebViewClientHostApi. */ + static MessageCodec getCodec() { + return WebViewClientHostApiCodec.INSTANCE; + } + + /** + * Sets up an instance of `WebViewClientHostApi` to handle messages through the + * `binaryMessenger`. + */ + static void setup(BinaryMessenger binaryMessenger, WebViewClientHostApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewClientHostApi.create", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Boolean shouldOverrideUrlLoadingArg = (Boolean) args.get(1); + if (shouldOverrideUrlLoadingArg == null) { + throw new NullPointerException( + "shouldOverrideUrlLoadingArg unexpectedly null."); + } + api.create(instanceIdArg.longValue(), shouldOverrideUrlLoadingArg); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewClientHostApi.dispose", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.dispose(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + + private static class WebViewClientFlutterApiCodec extends StandardMessageCodec { + public static final WebViewClientFlutterApiCodec INSTANCE = new WebViewClientFlutterApiCodec(); + + private WebViewClientFlutterApiCodec() {} + + @Override + protected Object readValueOfType(byte type, ByteBuffer buffer) { + switch (type) { + case (byte) 128: + return WebResourceErrorData.fromMap((Map) readValue(buffer)); + + case (byte) 129: + return WebResourceRequestData.fromMap((Map) readValue(buffer)); + + case (byte) 130: + return WebResourceRequestData.fromMap((Map) readValue(buffer)); + + default: + return super.readValueOfType(type, buffer); + } + } + + @Override + protected void writeValue(ByteArrayOutputStream stream, Object value) { + if (value instanceof WebResourceErrorData) { + stream.write(128); + writeValue(stream, ((WebResourceErrorData) value).toMap()); + } else if (value instanceof WebResourceRequestData) { + stream.write(129); + writeValue(stream, ((WebResourceRequestData) value).toMap()); + } else if (value instanceof WebResourceRequestData) { + stream.write(130); + writeValue(stream, ((WebResourceRequestData) value).toMap()); + } else { + super.writeValue(stream, value); + } + } + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java. */ + public static class WebViewClientFlutterApi { + private final BinaryMessenger binaryMessenger; + + public WebViewClientFlutterApi(BinaryMessenger argBinaryMessenger) { + this.binaryMessenger = argBinaryMessenger; + } + + public interface Reply { + void reply(T reply); + } + + static MessageCodec getCodec() { + return WebViewClientFlutterApiCodec.INSTANCE; + } + + public void onPageStarted( + Long instanceIdArg, Long webViewInstanceIdArg, String urlArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewClientFlutterApi.onPageStarted", + getCodec()); + channel.send( + new ArrayList(Arrays.asList(instanceIdArg, webViewInstanceIdArg, urlArg)), + channelReply -> { + callback.reply(null); + }); + } + + public void onPageFinished( + Long instanceIdArg, Long webViewInstanceIdArg, String urlArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewClientFlutterApi.onPageFinished", + getCodec()); + channel.send( + new ArrayList(Arrays.asList(instanceIdArg, webViewInstanceIdArg, urlArg)), + channelReply -> { + callback.reply(null); + }); + } + + public void onReceivedRequestError( + Long instanceIdArg, + Long webViewInstanceIdArg, + WebResourceRequestData requestArg, + WebResourceErrorData errorArg, + Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedRequestError", + getCodec()); + channel.send( + new ArrayList( + Arrays.asList(instanceIdArg, webViewInstanceIdArg, requestArg, errorArg)), + channelReply -> { + callback.reply(null); + }); + } + + public void onReceivedError( + Long instanceIdArg, + Long webViewInstanceIdArg, + Long errorCodeArg, + String descriptionArg, + String failingUrlArg, + Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError", + getCodec()); + channel.send( + new ArrayList( + Arrays.asList( + instanceIdArg, + webViewInstanceIdArg, + errorCodeArg, + descriptionArg, + failingUrlArg)), + channelReply -> { + callback.reply(null); + }); + } + + public void requestLoading( + Long instanceIdArg, + Long webViewInstanceIdArg, + WebResourceRequestData requestArg, + Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebViewClientFlutterApi.requestLoading", + getCodec()); + channel.send( + new ArrayList(Arrays.asList(instanceIdArg, webViewInstanceIdArg, requestArg)), + channelReply -> { + callback.reply(null); + }); + } + + public void urlLoading( + Long instanceIdArg, Long webViewInstanceIdArg, String urlArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebViewClientFlutterApi.urlLoading", getCodec()); + channel.send( + new ArrayList(Arrays.asList(instanceIdArg, webViewInstanceIdArg, urlArg)), + channelReply -> { + callback.reply(null); + }); + } + } + + private static class DownloadListenerHostApiCodec extends StandardMessageCodec { + public static final DownloadListenerHostApiCodec INSTANCE = new DownloadListenerHostApiCodec(); + + private DownloadListenerHostApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface DownloadListenerHostApi { + void create(Long instanceId); + + void dispose(Long instanceId); + + /** The codec used by DownloadListenerHostApi. */ + static MessageCodec getCodec() { + return DownloadListenerHostApiCodec.INSTANCE; + } + + /** + * Sets up an instance of `DownloadListenerHostApi` to handle messages through the + * `binaryMessenger`. + */ + static void setup(BinaryMessenger binaryMessenger, DownloadListenerHostApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.DownloadListenerHostApi.create", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.create(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.DownloadListenerHostApi.dispose", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.dispose(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + + private static class DownloadListenerFlutterApiCodec extends StandardMessageCodec { + public static final DownloadListenerFlutterApiCodec INSTANCE = + new DownloadListenerFlutterApiCodec(); + + private DownloadListenerFlutterApiCodec() {} + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java. */ + public static class DownloadListenerFlutterApi { + private final BinaryMessenger binaryMessenger; + + public DownloadListenerFlutterApi(BinaryMessenger argBinaryMessenger) { + this.binaryMessenger = argBinaryMessenger; + } + + public interface Reply { + void reply(T reply); + } + + static MessageCodec getCodec() { + return DownloadListenerFlutterApiCodec.INSTANCE; + } + + public void onDownloadStart( + Long instanceIdArg, + String urlArg, + String userAgentArg, + String contentDispositionArg, + String mimetypeArg, + Long contentLengthArg, + Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart", + getCodec()); + channel.send( + new ArrayList( + Arrays.asList( + instanceIdArg, + urlArg, + userAgentArg, + contentDispositionArg, + mimetypeArg, + contentLengthArg)), + channelReply -> { + callback.reply(null); + }); + } + } + + private static class WebChromeClientHostApiCodec extends StandardMessageCodec { + public static final WebChromeClientHostApiCodec INSTANCE = new WebChromeClientHostApiCodec(); + + private WebChromeClientHostApiCodec() {} + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ + public interface WebChromeClientHostApi { + void create(Long instanceId, Long webViewClientInstanceId); + + void dispose(Long instanceId); + + /** The codec used by WebChromeClientHostApi. */ + static MessageCodec getCodec() { + return WebChromeClientHostApiCodec.INSTANCE; + } + + /** + * Sets up an instance of `WebChromeClientHostApi` to handle messages through the + * `binaryMessenger`. + */ + static void setup(BinaryMessenger binaryMessenger, WebChromeClientHostApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebChromeClientHostApi.create", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + Number webViewClientInstanceIdArg = (Number) args.get(1); + if (webViewClientInstanceIdArg == null) { + throw new NullPointerException("webViewClientInstanceIdArg unexpectedly null."); + } + api.create(instanceIdArg.longValue(), webViewClientInstanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, "dev.flutter.pigeon.WebChromeClientHostApi.dispose", getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + Map wrapped = new HashMap<>(); + try { + ArrayList args = (ArrayList) message; + Number instanceIdArg = (Number) args.get(0); + if (instanceIdArg == null) { + throw new NullPointerException("instanceIdArg unexpectedly null."); + } + api.dispose(instanceIdArg.longValue()); + wrapped.put("result", null); + } catch (Error | RuntimeException exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + + private static class WebChromeClientFlutterApiCodec extends StandardMessageCodec { + public static final WebChromeClientFlutterApiCodec INSTANCE = + new WebChromeClientFlutterApiCodec(); + + private WebChromeClientFlutterApiCodec() {} + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java. */ + public static class WebChromeClientFlutterApi { + private final BinaryMessenger binaryMessenger; + + public WebChromeClientFlutterApi(BinaryMessenger argBinaryMessenger) { + this.binaryMessenger = argBinaryMessenger; + } + + public interface Reply { + void reply(T reply); + } + + static MessageCodec getCodec() { + return WebChromeClientFlutterApiCodec.INSTANCE; + } + + public void onProgressChanged( + Long instanceIdArg, Long webViewInstanceIdArg, Long progressArg, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.WebChromeClientFlutterApi.onProgressChanged", + getCodec()); + channel.send( + new ArrayList(Arrays.asList(instanceIdArg, webViewInstanceIdArg, progressArg)), + channelReply -> { + callback.reply(null); + }); + } + } + + private static Map wrapError(Throwable exception) { + Map errorMap = new HashMap<>(); + errorMap.put("message", exception.toString()); + errorMap.put("code", exception.getClass().getSimpleName()); + errorMap.put("details", null); + return errorMap; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java new file mode 100644 index 000000000000..0a4cb3effd29 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/InputAwareWebView.java @@ -0,0 +1,233 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static android.content.Context.INPUT_METHOD_SERVICE; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.webkit.WebView; +import android.widget.ListPopupWindow; + +/** + * A WebView subclass that mirrors the same implementation hacks that the system WebView does in + * order to correctly create an InputConnection. + * + *

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

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

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

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

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

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

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

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

Exposes a single method named `postMessage` to JavaScript, which sends a message over a method + * channel to the Dart code. + */ +class JavaScriptChannel { + private final MethodChannel methodChannel; + final String javaScriptChannelName; + private final Handler platformThreadHandler; + + /** + * @param methodChannel the Flutter WebView method channel to which JS messages are sent + * @param javaScriptChannelName the name of the JavaScript channel, this is sent over the method + * channel with each message to let the Dart code know which JavaScript channel the message + * was sent through + */ + JavaScriptChannel( + MethodChannel methodChannel, String javaScriptChannelName, Handler platformThreadHandler) { + this.methodChannel = methodChannel; + this.javaScriptChannelName = javaScriptChannelName; + this.platformThreadHandler = platformThreadHandler; + } + + // Suppressing unused warning as this is invoked from JavaScript. + @SuppressWarnings("unused") + @JavascriptInterface + public void postMessage(final String message) { + Runnable postMessageRunnable = + new Runnable() { + @Override + public void run() { + HashMap arguments = new HashMap<>(); + arguments.put("channel", javaScriptChannelName); + arguments.put("message", message); + methodChannel.invokeMethod("javascriptChannelMessage", arguments); + } + }; + if (platformThreadHandler.getLooper() == Looper.myLooper()) { + postMessageRunnable.run(); + } else { + platformThreadHandler.post(postMessageRunnable); + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannelHostApiImpl.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannelHostApiImpl.java new file mode 100644 index 000000000000..2d42d952955c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/JavaScriptChannelHostApiImpl.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 android.os.Handler; +import android.os.Looper; +import io.flutter.plugins.webviewflutter.GeneratedAndroidWebView.JavaScriptChannelFlutterApi; + +class JavaScriptChannelHostApiImpl implements GeneratedAndroidWebView.JavaScriptChannelHostApi { + private final InstanceManager instanceManager; + private final JavaScriptChannelCreator javaScriptChannelCreator; + private final JavaScriptChannelFlutterApi javaScriptChannelFlutterApi; + private final Handler platformThreadHandler; + + static class JavaScriptChannelCreator { + JavaScriptChannel createJavaScriptChannel( + Long instanceId, + JavaScriptChannelFlutterApi javaScriptChannelFlutterApi, + String channelName, + Handler platformThreadHandler) { + return new JavaScriptChannel(null, channelName, platformThreadHandler) { + @Override + public void postMessage(String message) { + final Runnable postMessageRunnable = + () -> javaScriptChannelFlutterApi.postMessage(instanceId, message, reply -> {}); + if (platformThreadHandler.getLooper() == Looper.myLooper()) { + postMessageRunnable.run(); + } else { + platformThreadHandler.post(postMessageRunnable); + } + } + }; + } + } + + JavaScriptChannelHostApiImpl( + InstanceManager instanceManager, + JavaScriptChannelCreator javaScriptChannelCreator, + JavaScriptChannelFlutterApi javaScriptChannelFlutterApi, + Handler platformThreadHandler) { + this.instanceManager = instanceManager; + this.javaScriptChannelCreator = javaScriptChannelCreator; + this.javaScriptChannelFlutterApi = javaScriptChannelFlutterApi; + this.platformThreadHandler = platformThreadHandler; + } + + @Override + public void create(Long instanceId, String channelName) { + final JavaScriptChannel javaScriptChannel = + javaScriptChannelCreator.createJavaScriptChannel( + instanceId, javaScriptChannelFlutterApi, channelName, platformThreadHandler); + instanceManager.addInstance(javaScriptChannel, instanceId); + } + + @Override + public void dispose(Long instanceId) { + instanceManager.removeInstance(instanceId); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java new file mode 100644 index 000000000000..1c865c9444e2 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/ThreadedInputConnectionProxyAdapterView.java @@ -0,0 +1,112 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.os.Handler; +import android.os.IBinder; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +/** + * A fake View only exposed to InputMethodManager. + * + *

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

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

Delegates to ThreadedInputConnectionProxyView to get WebView's input connection. + */ + @Override + public InputConnection onCreateInputConnection(final EditorInfo outAttrs) { + triggerDelayed = false; + InputConnection inputConnection = + (isLocked) ? cachedConnection : targetView.onCreateInputConnection(outAttrs); + triggerDelayed = true; + cachedConnection = inputConnection; + return inputConnection; + } + + @Override + public boolean checkInputConnectionProxy(View view) { + return true; + } + + @Override + public boolean hasWindowFocus() { + // None of our views here correctly report they have window focus because of how we're embedding + // the platform view inside of a virtual display. + return true; + } + + @Override + public View getRootView() { + return rootView; + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public boolean isFocused() { + return true; + } + + @Override + public IBinder getWindowToken() { + return windowToken; + } + + @Override + public Handler getHandler() { + return imeHandler; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebChromeClientHostApiImpl.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebChromeClientHostApiImpl.java new file mode 100644 index 000000000000..32f8fcbdeed9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebChromeClientHostApiImpl.java @@ -0,0 +1,92 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.os.Build; +import android.os.Message; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import io.flutter.plugins.webviewflutter.GeneratedAndroidWebView.WebChromeClientFlutterApi; + +class WebChromeClientHostApiImpl implements GeneratedAndroidWebView.WebChromeClientHostApi { + private final InstanceManager instanceManager; + private final WebChromeClientCreator webChromeClientCreator; + private final WebChromeClientFlutterApi webChromeClientFlutterApi; + + static class WebChromeClientCreator { + WebChromeClient createWebChromeClient( + Long instanceId, + InstanceManager instanceManager, + WebViewClient webViewClient, + WebChromeClientFlutterApi webChromeClientFlutterApi) { + return new WebChromeClient() { + // Verifies that a url opened by `Window.open` has a secure url. + @Override + public boolean onCreateWindow( + final WebView view, boolean isDialog, boolean isUserGesture, Message resultMsg) { + final WebViewClient newWindowWebViewClient = + new WebViewClient() { + @RequiresApi(api = Build.VERSION_CODES.N) + @Override + public boolean shouldOverrideUrlLoading( + @NonNull WebView view, @NonNull WebResourceRequest request) { + webViewClient.shouldOverrideUrlLoading(view, request); + return true; + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + webViewClient.shouldOverrideUrlLoading(view, url); + return true; + } + }; + + final WebView newWebView = new WebView(view.getContext()); + newWebView.setWebViewClient(newWindowWebViewClient); + + final WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj; + transport.setWebView(newWebView); + resultMsg.sendToTarget(); + + return true; + } + + @Override + public void onProgressChanged(WebView view, int progress) { + webChromeClientFlutterApi.onProgressChanged( + instanceId, instanceManager.getInstanceId(view), (long) progress, reply -> {}); + } + }; + } + } + + WebChromeClientHostApiImpl( + InstanceManager instanceManager, + WebChromeClientCreator webChromeClientCreator, + WebChromeClientFlutterApi webChromeClientFlutterApi) { + this.instanceManager = instanceManager; + this.webChromeClientCreator = webChromeClientCreator; + this.webChromeClientFlutterApi = webChromeClientFlutterApi; + } + + @Override + public void create(Long instanceId, Long webViewClientInstanceId) { + final WebViewClient webViewClient = + (WebViewClient) instanceManager.getInstance(webViewClientInstanceId); + final WebChromeClient webChromeClient = + webChromeClientCreator.createWebChromeClient( + instanceId, instanceManager, webViewClient, webChromeClientFlutterApi); + instanceManager.addInstance(webChromeClient, instanceId); + } + + @Override + public void dispose(Long instanceId) { + instanceManager.removeInstance(instanceId); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebSettingsHostApiImpl.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebSettingsHostApiImpl.java new file mode 100644 index 000000000000..e70a867c23ff --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebSettingsHostApiImpl.java @@ -0,0 +1,101 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.webkit.WebSettings; +import android.webkit.WebView; + +class WebSettingsHostApiImpl implements GeneratedAndroidWebView.WebSettingsHostApi { + private final InstanceManager instanceManager; + private final WebSettingsCreator webSettingsCreator; + + static class WebSettingsCreator { + WebSettings createWebSettings(WebView webView) { + return webView.getSettings(); + } + } + + WebSettingsHostApiImpl(InstanceManager instanceManager, WebSettingsCreator webSettingsCreator) { + this.instanceManager = instanceManager; + this.webSettingsCreator = webSettingsCreator; + } + + @Override + public void create(Long instanceId, Long webViewInstanceId) { + final WebView webView = (WebView) instanceManager.getInstance(webViewInstanceId); + instanceManager.addInstance(webSettingsCreator.createWebSettings(webView), instanceId); + } + + @Override + public void dispose(Long instanceId) { + instanceManager.removeInstance(instanceId); + } + + @Override + public void setDomStorageEnabled(Long instanceId, Boolean flag) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setDomStorageEnabled(flag); + } + + @Override + public void setJavaScriptCanOpenWindowsAutomatically(Long instanceId, Boolean flag) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setJavaScriptCanOpenWindowsAutomatically(flag); + } + + @Override + public void setSupportMultipleWindows(Long instanceId, Boolean support) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setSupportMultipleWindows(support); + } + + @Override + public void setJavaScriptEnabled(Long instanceId, Boolean flag) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setJavaScriptEnabled(flag); + } + + @Override + public void setUserAgentString(Long instanceId, String userAgentString) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setUserAgentString(userAgentString); + } + + @Override + public void setMediaPlaybackRequiresUserGesture(Long instanceId, Boolean require) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setMediaPlaybackRequiresUserGesture(require); + } + + @Override + public void setSupportZoom(Long instanceId, Boolean support) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setSupportZoom(support); + } + + @Override + public void setLoadWithOverviewMode(Long instanceId, Boolean overview) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setLoadWithOverviewMode(overview); + } + + @Override + public void setUseWideViewPort(Long instanceId, Boolean use) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setUseWideViewPort(use); + } + + @Override + public void setDisplayZoomControls(Long instanceId, Boolean enabled) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setDisplayZoomControls(enabled); + } + + @Override + public void setBuiltInZoomControls(Long instanceId, Boolean enabled) { + final WebSettings webSettings = (WebSettings) instanceManager.getInstance(instanceId); + webSettings.setBuiltInZoomControls(enabled); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java new file mode 100644 index 000000000000..e0d5e8815f31 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewBuilder.java @@ -0,0 +1,172 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.content.Context; +import android.view.View; +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** Builder used to create {@link android.webkit.WebView} objects. */ +public class WebViewBuilder { + + /** Factory used to create a new {@link android.webkit.WebView} instance. */ + static class WebViewFactory { + + /** + * Creates a new {@link android.webkit.WebView} instance. + * + * @param context an Activity Context to access application assets. This value cannot be null. + * @param usesHybridComposition If {@code false} a {@link InputAwareWebView} instance is + * returned. + * @param containerView must be supplied when the {@code useHybridComposition} parameter is set + * to {@code false}. Used to create an InputConnection on the WebView's dedicated input, or + * IME, thread (see also {@link InputAwareWebView}) + * @return A new instance of the {@link android.webkit.WebView} object. + */ + static WebView create(Context context, boolean usesHybridComposition, View containerView) { + return usesHybridComposition + ? new WebView(context) + : new InputAwareWebView(context, containerView); + } + } + + private final Context context; + private final View containerView; + + private boolean enableDomStorage; + private boolean javaScriptCanOpenWindowsAutomatically; + private boolean supportMultipleWindows; + private boolean usesHybridComposition; + private WebChromeClient webChromeClient; + private DownloadListener downloadListener; + private boolean enableBuiltInZoomControls; + + /** + * Constructs a new {@link WebViewBuilder} object with a custom implementation of the {@link + * WebViewFactory} object. + * + * @param context an Activity Context to access application assets. This value cannot be null. + * @param containerView must be supplied when the {@code useHybridComposition} parameter is set to + * {@code false}. Used to create an InputConnection on the WebView's dedicated input, or IME, + * thread (see also {@link InputAwareWebView}) + */ + WebViewBuilder(@NonNull final Context context, View containerView) { + this.context = context; + this.containerView = containerView; + } + + /** + * Sets whether the DOM storage API is enabled. The default value is {@code false}. + * + * @param flag {@code true} is {@link android.webkit.WebView} should use the DOM storage API. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setDomStorageEnabled(boolean flag) { + this.enableDomStorage = flag; + return this; + } + + /** + * Sets whether JavaScript is allowed to open windows automatically. This applies to the + * JavaScript function {@code window.open()}. The default value is {@code false}. + * + * @param flag {@code true} if JavaScript is allowed to open windows automatically. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setJavaScriptCanOpenWindowsAutomatically(boolean flag) { + this.javaScriptCanOpenWindowsAutomatically = flag; + return this; + } + + /** + * Sets whether the {@link WebView} supports multiple windows. If set to {@code true}, {@link + * WebChromeClient#onCreateWindow} must be implemented by the host application. The default is + * {@code false}. + * + * @param flag {@code true} if multiple windows are supported. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setSupportMultipleWindows(boolean flag) { + this.supportMultipleWindows = flag; + return this; + } + + /** + * Sets whether the hybrid composition should be used. + * + *

If set to {@code true} a standard {@link WebView} is created. If set to {@code false} the + * {@link WebViewBuilder} will create a {@link InputAwareWebView} to workaround issues using the + * {@link WebView} on Android versions below N. + * + * @param flag {@code true} if uses hybrid composition. The default is {@code false}. + * @return This builder. This value cannot be {@code null} + */ + public WebViewBuilder setUsesHybridComposition(boolean flag) { + this.usesHybridComposition = flag; + return this; + } + + /** + * Sets the chrome handler. This is an implementation of WebChromeClient for use in handling + * JavaScript dialogs, favicons, titles, and the progress. This will replace the current handler. + * + * @param webChromeClient an implementation of WebChromeClient This value may be null. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setWebChromeClient(@Nullable WebChromeClient webChromeClient) { + this.webChromeClient = webChromeClient; + return this; + } + + /** + * Registers the interface to be used when content can not be handled by the rendering engine, and + * should be downloaded instead. This will replace the current handler. + * + * @param downloadListener an implementation of DownloadListener This value may be null. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setDownloadListener(@Nullable DownloadListener downloadListener) { + this.downloadListener = downloadListener; + return this; + } + + /** + * Sets whether the {@link WebView} should use its built-in zoom mechanisms. The default value is + * {@code true}. + * + * @param flag {@code true} if built in zoom controls are allowed. + * @return This builder. This value cannot be {@code null}. + */ + public WebViewBuilder setZoomControlsEnabled(boolean flag) { + this.enableBuiltInZoomControls = flag; + 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); + webSettings.setLoadWithOverviewMode(true); + webSettings.setUseWideViewPort(true); + webSettings.setDisplayZoomControls(false); + webSettings.setBuiltInZoomControls(enableBuiltInZoomControls); + webView.setWebChromeClient(webChromeClient); + webView.setDownloadListener(downloadListener); + return webView; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewClientHostApiImpl.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewClientHostApiImpl.java new file mode 100644 index 000000000000..4d17eb129db8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewClientHostApiImpl.java @@ -0,0 +1,239 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.graphics.Bitmap; +import android.os.Build; +import android.view.KeyEvent; +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.webkit.WebResourceErrorCompat; +import androidx.webkit.WebViewClientCompat; +import io.flutter.plugins.webviewflutter.GeneratedAndroidWebView.WebViewClientFlutterApi; + +class WebViewClientHostApiImpl implements GeneratedAndroidWebView.WebViewClientHostApi { + private final InstanceManager instanceManager; + private final WebViewClientCreator webViewClientCreator; + private final WebViewClientFlutterApi webViewClientFlutterApi; + + @RequiresApi(api = Build.VERSION_CODES.M) + static GeneratedAndroidWebView.WebResourceErrorData createWebResourceErrorData( + WebResourceError error) { + final GeneratedAndroidWebView.WebResourceErrorData errorData = + new GeneratedAndroidWebView.WebResourceErrorData(); + errorData.setErrorCode((long) error.getErrorCode()); + errorData.setDescription(error.getDescription().toString()); + + return errorData; + } + + @SuppressLint("RequiresFeature") + static GeneratedAndroidWebView.WebResourceErrorData createWebResourceErrorData( + WebResourceErrorCompat error) { + final GeneratedAndroidWebView.WebResourceErrorData errorData = + new GeneratedAndroidWebView.WebResourceErrorData(); + errorData.setErrorCode((long) error.getErrorCode()); + errorData.setDescription(error.getDescription().toString()); + + return errorData; + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + static GeneratedAndroidWebView.WebResourceRequestData createWebResourceRequestData( + WebResourceRequest request) { + final GeneratedAndroidWebView.WebResourceRequestData requestData = + new GeneratedAndroidWebView.WebResourceRequestData(); + requestData.setUrl(request.getUrl().toString()); + requestData.setIsForMainFrame(request.isForMainFrame()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + requestData.setIsRedirect(request.isRedirect()); + } + requestData.setHasGesture(request.hasGesture()); + requestData.setMethod(request.getMethod()); + requestData.setRequestHeaders(request.getRequestHeaders()); + + return requestData; + } + + static class WebViewClientCreator { + WebViewClient createWebViewClient( + Long instanceId, + InstanceManager instanceManager, + Boolean shouldOverrideUrlLoading, + WebViewClientFlutterApi webViewClientFlutterApi) { + // WebViewClientCompat is used to get + // shouldOverrideUrlLoading(WebView view, WebResourceRequest request) + // invoked by the webview on older Android devices, without it pages that use iframes will + // be broken when a navigationDelegate is set on Android version earlier than N. + // + // However, this if statement attempts to avoid using WebViewClientCompat on versions >= N due + // to bug https://bugs.chromium.org/p/chromium/issues/detail?id=925887. Also, see + // https://github.com/flutter/flutter/issues/29446. + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return new WebViewClient() { + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + webViewClientFlutterApi.onPageStarted( + instanceId, instanceManager.getInstanceId(view), url, reply -> {}); + } + + @Override + public void onPageFinished(WebView view, String url) { + webViewClientFlutterApi.onPageFinished( + instanceId, instanceManager.getInstanceId(view), url, reply -> {}); + } + + @Override + public void onReceivedError( + WebView view, WebResourceRequest request, WebResourceError error) { + webViewClientFlutterApi.onReceivedRequestError( + instanceId, + instanceManager.getInstanceId(view), + createWebResourceRequestData(request), + createWebResourceErrorData(error), + reply -> {}); + } + + @SuppressWarnings("deprecation") + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + webViewClientFlutterApi.onReceivedError( + instanceId, + instanceManager.getInstanceId(view), + (long) errorCode, + description, + failingUrl, + reply -> {}); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + webViewClientFlutterApi.requestLoading( + instanceId, + instanceManager.getInstanceId(view), + createWebResourceRequestData(request), + reply -> {}); + return shouldOverrideUrlLoading; + } + + @SuppressWarnings("deprecation") + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + webViewClientFlutterApi.urlLoading( + instanceId, instanceManager.getInstanceId(view), url, reply -> {}); + return shouldOverrideUrlLoading; + } + + @Override + public void onUnhandledKeyEvent(WebView view, KeyEvent event) { + // Deliberately empty. Occasionally the webview will mark events as having failed to be + // handled even though they were handled. We don't want to propagate those as they're not + // truly lost. + } + }; + } else { + return new WebViewClientCompat() { + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + webViewClientFlutterApi.onPageStarted( + instanceId, instanceManager.getInstanceId(view), url, reply -> {}); + } + + @Override + public void onPageFinished(WebView view, String url) { + webViewClientFlutterApi.onPageFinished( + instanceId, instanceManager.getInstanceId(view), url, reply -> {}); + } + + // This method is only called when the WebViewFeature.RECEIVE_WEB_RESOURCE_ERROR feature is + // enabled. The deprecated method is called when a device doesn't support this. + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @SuppressLint("RequiresFeature") + @Override + public void onReceivedError( + @NonNull WebView view, + @NonNull WebResourceRequest request, + @NonNull WebResourceErrorCompat error) { + webViewClientFlutterApi.onReceivedRequestError( + instanceId, + instanceManager.getInstanceId(view), + createWebResourceRequestData(request), + createWebResourceErrorData(error), + reply -> {}); + } + + @SuppressWarnings("deprecation") + @Override + public void onReceivedError( + WebView view, int errorCode, String description, String failingUrl) { + webViewClientFlutterApi.onReceivedError( + instanceId, + instanceManager.getInstanceId(view), + (long) errorCode, + description, + failingUrl, + reply -> {}); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean shouldOverrideUrlLoading( + @NonNull WebView view, @NonNull WebResourceRequest request) { + webViewClientFlutterApi.requestLoading( + instanceId, + instanceManager.getInstanceId(view), + createWebResourceRequestData(request), + reply -> {}); + return shouldOverrideUrlLoading; + } + + @SuppressWarnings("deprecation") + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + webViewClientFlutterApi.urlLoading( + instanceId, instanceManager.getInstanceId(view), url, reply -> {}); + return shouldOverrideUrlLoading; + } + + @Override + public void onUnhandledKeyEvent(WebView view, KeyEvent event) { + // Deliberately empty. Occasionally the webview will mark events as having failed to be + // handled even though they were handled. We don't want to propagate those as they're not + // truly lost. + } + }; + } + } + } + + WebViewClientHostApiImpl( + InstanceManager instanceManager, + WebViewClientCreator webViewClientCreator, + WebViewClientFlutterApi webViewClientFlutterApi) { + this.instanceManager = instanceManager; + this.webViewClientCreator = webViewClientCreator; + this.webViewClientFlutterApi = webViewClientFlutterApi; + } + + @Override + public void create(Long instanceId, Boolean shouldOverrideUrlLoading) { + final WebViewClient webViewClient = + webViewClientCreator.createWebViewClient( + instanceId, instanceManager, shouldOverrideUrlLoading, webViewClientFlutterApi); + instanceManager.addInstance(webViewClient, instanceId); + } + + @Override + public void dispose(Long instanceId) { + instanceManager.removeInstance(instanceId); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java new file mode 100644 index 000000000000..268d35a1e04c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewFlutterPlugin.java @@ -0,0 +1,73 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; + +/** + * Java platform implementation of the webview_flutter plugin. + * + *

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

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

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

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

Calling this automatically initializes the plugin. However plugins initialized this way + * won't react to changes in activity or context, unlike {@link CameraPlugin}. + */ + @SuppressWarnings("deprecation") + public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registrar registrar) { + registrar + .platformViewRegistry() + .registerViewFactory( + "plugins.flutter.io/webview", + new FlutterWebViewFactory(registrar.messenger(), registrar.view())); + new FlutterCookieManager(registrar.messenger()); + } + + @Override + public void onAttachedToEngine(FlutterPluginBinding binding) { + BinaryMessenger messenger = binding.getBinaryMessenger(); + binding + .getPlatformViewRegistry() + .registerViewFactory( + "plugins.flutter.io/webview", + new FlutterWebViewFactory(messenger, /*containerView=*/ null)); + flutterCookieManager = new FlutterCookieManager(messenger); + } + + @Override + public void onDetachedFromEngine(FlutterPluginBinding binding) { + if (flutterCookieManager == null) { + return; + } + + flutterCookieManager.dispose(); + flutterCookieManager = null; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewHostApiImpl.java b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewHostApiImpl.java new file mode 100644 index 000000000000..35bdc608d6ff --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/main/java/io/flutter/plugins/webviewflutter/WebViewHostApiImpl.java @@ -0,0 +1,233 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import android.content.Context; +import android.view.View; +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.annotation.NonNull; +import io.flutter.plugin.platform.PlatformView; +import java.util.Map; + +class WebViewHostApiImpl implements GeneratedAndroidWebView.WebViewHostApi { + private final InstanceManager instanceManager; + private final WebViewProxy webViewProxy; + private final Context context; + + static class WebViewProxy { + WebView createWebView(Context context) { + return new WebViewPlatformView(context); + } + + WebView createInputAwareWebView(Context context) { + return new InputAwareWebViewPlatformView(context, null); + } + + void setWebContentsDebuggingEnabled(boolean enabled) { + WebView.setWebContentsDebuggingEnabled(enabled); + } + } + + private static class WebViewPlatformView extends WebView implements PlatformView { + public WebViewPlatformView(Context context) { + super(context); + } + + @Override + public View getView() { + return this; + } + + @Override + public void dispose() { + destroy(); + } + } + + private static class InputAwareWebViewPlatformView extends InputAwareWebView + implements PlatformView { + InputAwareWebViewPlatformView(Context context, View containerView) { + super(context, containerView); + } + + @Override + public View getView() { + return this; + } + + @Override + public void onFlutterViewAttached(@NonNull View flutterView) { + setContainerView(flutterView); + } + + @Override + public void onFlutterViewDetached() { + setContainerView(null); + } + + @Override + public void dispose() { + dispose(); + destroy(); + } + + @Override + public void onInputConnectionLocked() { + lockInputConnection(); + } + + @Override + public void onInputConnectionUnlocked() { + unlockInputConnection(); + } + } + + WebViewHostApiImpl(InstanceManager instanceManager, WebViewProxy webViewProxy, Context context) { + this.instanceManager = instanceManager; + this.webViewProxy = webViewProxy; + this.context = context; + } + + @Override + public void create(Long instanceId, Boolean useHybridComposition) { + final WebView webView = + useHybridComposition + ? webViewProxy.createWebView(context) + : webViewProxy.createInputAwareWebView(context); + instanceManager.addInstance(webView, instanceId); + } + + @Override + public void dispose(Long instanceId) { + instanceManager.removeInstance(instanceId); + } + + @Override + public void loadUrl(Long instanceId, String url, Map headers) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.loadUrl(url, headers); + } + + @Override + public String getUrl(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + return webView.getUrl(); + } + + @Override + public Boolean canGoBack(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + return webView.canGoBack(); + } + + @Override + public Boolean canGoForward(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + return webView.canGoForward(); + } + + @Override + public void goBack(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.goBack(); + } + + @Override + public void goForward(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.goForward(); + } + + @Override + public void reload(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.reload(); + } + + @Override + public void clearCache(Long instanceId, Boolean includeDiskFiles) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.clearCache(includeDiskFiles); + } + + @Override + public void evaluateJavascript( + Long instanceId, String javascriptString, GeneratedAndroidWebView.Result result) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.evaluateJavascript(javascriptString, result::success); + } + + @Override + public String getTitle(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + return webView.getTitle(); + } + + @Override + public void scrollTo(Long instanceId, Long x, Long y) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.scrollTo(x.intValue(), y.intValue()); + } + + @Override + public void scrollBy(Long instanceId, Long x, Long y) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.scrollBy(x.intValue(), y.intValue()); + } + + @Override + public Long getScrollX(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + return (long) webView.getScrollX(); + } + + @Override + public Long getScrollY(Long instanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + return (long) webView.getScrollY(); + } + + @Override + public void setWebContentsDebuggingEnabled(Boolean enabled) { + webViewProxy.setWebContentsDebuggingEnabled(enabled); + } + + @Override + public void setWebViewClient(Long instanceId, Long webViewClientInstanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.setWebViewClient((WebViewClient) instanceManager.getInstance(webViewClientInstanceId)); + } + + @Override + public void addJavaScriptChannel(Long instanceId, Long javaScriptChannelInstanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + final JavaScriptChannel javaScriptChannel = + (JavaScriptChannel) instanceManager.getInstance(javaScriptChannelInstanceId); + webView.addJavascriptInterface(javaScriptChannel, javaScriptChannel.javaScriptChannelName); + } + + @Override + public void removeJavaScriptChannel(Long instanceId, Long javaScriptChannelInstanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + final JavaScriptChannel javaScriptChannel = + (JavaScriptChannel) instanceManager.getInstance(javaScriptChannelInstanceId); + webView.removeJavascriptInterface(javaScriptChannel.javaScriptChannelName); + } + + @Override + public void setDownloadListener(Long instanceId, Long listenerInstanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.setDownloadListener((DownloadListener) instanceManager.getInstance(listenerInstanceId)); + } + + @Override + public void setWebChromeClient(Long instanceId, Long clientInstanceId) { + final WebView webView = (WebView) instanceManager.getInstance(instanceId); + webView.setWebChromeClient((WebChromeClient) instanceManager.getInstance(instanceId)); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/android/util/LongSparseArray.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/android/util/LongSparseArray.java new file mode 100644 index 000000000000..4a90e394e259 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/android/util/LongSparseArray.java @@ -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. + +package android.util; + +import java.util.HashMap; + +// Creates an implementation of LongSparseArray that can be used with unittests and the JVM. +// Typically android.util.LongSparseArray does nothing when not used with an Android environment. +public class LongSparseArray { + private final HashMap mHashMap; + + public LongSparseArray() { + mHashMap = new HashMap<>(); + } + + public void append(long key, E value) { + mHashMap.put(key, value); + } + + public E get(long key) { + return mHashMap.get(key); + } + + public void remove(long key) { + mHashMap.remove(key); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/DownloadListenerTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/DownloadListenerTest.java new file mode 100644 index 000000000000..5ba073c7f418 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/DownloadListenerTest.java @@ -0,0 +1,65 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import android.webkit.DownloadListener; +import io.flutter.plugins.webviewflutter.DownloadListenerHostApiImpl.DownloadListenerCreator; +import io.flutter.plugins.webviewflutter.GeneratedAndroidWebView.DownloadListenerFlutterApi; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +public class DownloadListenerTest { + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock public DownloadListenerFlutterApi mockFlutterApi; + + InstanceManager testInstanceManager; + DownloadListenerHostApiImpl testHostApiImpl; + DownloadListener testDownloadListener; + + @Before + public void setUp() { + testInstanceManager = new InstanceManager(); + + final DownloadListenerCreator downloadListenerCreator = + new DownloadListenerCreator() { + @Override + DownloadListener createDownloadListener( + Long instanceId, DownloadListenerFlutterApi downloadListenerFlutterApi) { + testDownloadListener = + super.createDownloadListener(instanceId, downloadListenerFlutterApi); + return testDownloadListener; + } + }; + + testHostApiImpl = + new DownloadListenerHostApiImpl( + testInstanceManager, downloadListenerCreator, mockFlutterApi); + testHostApiImpl.create(0L); + } + + @Test + public void postMessage() { + testDownloadListener.onDownloadStart( + "https://www.google.com", "userAgent", "contentDisposition", "mimetype", 54); + verify(mockFlutterApi) + .onDownloadStart( + eq(0L), + eq("https://www.google.com"), + eq("userAgent"), + eq("contentDisposition"), + eq("mimetype"), + eq(54L), + any()); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java new file mode 100644 index 000000000000..2c918584ba83 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterDownloadListenerTest.java @@ -0,0 +1,42 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.webkit.WebView; +import org.junit.Before; +import org.junit.Test; + +public class FlutterDownloadListenerTest { + private FlutterWebViewClient webViewClient; + private WebView webView; + + @Before + public void before() { + webViewClient = mock(FlutterWebViewClient.class); + webView = mock(WebView.class); + } + + @Test + public void onDownloadStart_should_notify_webViewClient() { + String url = "testurl.com"; + FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); + downloadListener.onDownloadStart(url, "test", "inline", "data/text", 0); + verify(webViewClient).notifyDownload(nullable(WebView.class), eq(url)); + } + + @Test + public void onDownloadStart_should_pass_webView() { + FlutterDownloadListener downloadListener = new FlutterDownloadListener(webViewClient); + downloadListener.setWebView(webView); + downloadListener.onDownloadStart("testurl.com", "test", "inline", "data/text", 0); + verify(webViewClient).notifyDownload(eq(webView), anyString()); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java new file mode 100644 index 000000000000..86346ac08f16 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewClientTest.java @@ -0,0 +1,60 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import android.webkit.WebView; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +public class FlutterWebViewClientTest { + + MethodChannel mockMethodChannel; + WebView mockWebView; + + @Before + public void before() { + mockMethodChannel = mock(MethodChannel.class); + mockWebView = mock(WebView.class); + } + + @Test + public void notify_download_should_notifyOnNavigationRequest_when_navigationDelegate_is_set() { + final String url = "testurl.com"; + + FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); + client.createWebViewClient(true); + + client.notifyDownload(mockWebView, url); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Object.class); + verify(mockMethodChannel) + .invokeMethod( + eq("navigationRequest"), argumentCaptor.capture(), any(MethodChannel.Result.class)); + HashMap map = (HashMap) argumentCaptor.getValue(); + assertEquals(map.get("url"), url); + assertEquals(map.get("isForMainFrame"), true); + } + + @Test + public void + notify_download_should_not_notifyOnNavigationRequest_when_navigationDelegate_is_not_set() { + final String url = "testurl.com"; + + FlutterWebViewClient client = new FlutterWebViewClient(mockMethodChannel); + client.createWebViewClient(false); + + client.notifyDownload(mockWebView, url); + verifyNoInteractions(mockMethodChannel); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java new file mode 100644 index 000000000000..f26a0ea6b9cc --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/FlutterWebViewTest.java @@ -0,0 +1,173 @@ +// Copyright 2013 The Flutter 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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +public class FlutterWebViewTest { + private WebChromeClient mockWebChromeClient; + private DownloadListener mockDownloadListener; + private WebViewBuilder mockWebViewBuilder; + private WebView mockWebView; + private MethodChannel.Result mockResult; + private Context mockContext; + private MethodChannel mockMethodChannel; + + @Before + public void before() { + + mockWebChromeClient = mock(WebChromeClient.class); + mockWebViewBuilder = mock(WebViewBuilder.class); + mockWebView = mock(WebView.class); + mockDownloadListener = mock(DownloadListener.class); + mockResult = mock(MethodChannel.Result.class); + mockContext = mock(Context.class); + mockMethodChannel = mock(MethodChannel.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.setZoomControlsEnabled(anyBoolean())).thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setWebChromeClient(any(WebChromeClient.class))) + .thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.setDownloadListener(any(DownloadListener.class))) + .thenReturn(mockWebViewBuilder); + when(mockWebViewBuilder.build()).thenReturn(mockWebView); + } + + @Test + public void createWebView_shouldCreateWebViewWithDefaultConfiguration() { + FlutterWebView.createWebView( + mockWebViewBuilder, createParameterMap(false), mockWebChromeClient, mockDownloadListener); + + verify(mockWebViewBuilder, times(1)).setDomStorageEnabled(true); + verify(mockWebViewBuilder, times(1)).setJavaScriptCanOpenWindowsAutomatically(true); + verify(mockWebViewBuilder, times(1)).setSupportMultipleWindows(true); + verify(mockWebViewBuilder, times(1)).setUsesHybridComposition(false); + verify(mockWebViewBuilder, times(1)).setWebChromeClient(mockWebChromeClient); + verify(mockWebViewBuilder, times(1)).setZoomControlsEnabled(true); + } + + @Test(expected = UnsupportedOperationException.class) + public void evaluateJavaScript_shouldThrowForNullString() { + try (MockedStatic mockedFlutterWebView = mockStatic(FlutterWebView.class)) { + // Setup + mockedFlutterWebView + .when( + new MockedStatic.Verification() { + @Override + public void apply() throws Throwable { + FlutterWebView.createWebView( + (WebViewBuilder) any(), + (Map) any(), + (WebChromeClient) any(), + (DownloadListener) any()); + } + }) + .thenReturn(mockWebView); + FlutterWebView flutterWebView = + new FlutterWebView(mockContext, mockMethodChannel, new HashMap(), null); + + // Run + flutterWebView.onMethodCall(new MethodCall("runJavascript", null), mockResult); + } + } + + @Test + public void evaluateJavaScript_shouldReturnValueOnSuccessForReturnValue() { + try (MockedStatic mockedFlutterWebView = mockStatic(FlutterWebView.class)) { + // Setup + mockedFlutterWebView + .when( + () -> + FlutterWebView.createWebView( + (WebViewBuilder) any(), + (Map) any(), + (WebChromeClient) any(), + (DownloadListener) any())) + .thenReturn(mockWebView); + doAnswer( + invocation -> { + android.webkit.ValueCallback callback = invocation.getArgument(1); + callback.onReceiveValue("Test JavaScript Result"); + return null; + }) + .when(mockWebView) + .evaluateJavascript(eq("Test JavaScript String"), any()); + FlutterWebView flutterWebView = + new FlutterWebView(mockContext, mockMethodChannel, new HashMap(), null); + + // Run + flutterWebView.onMethodCall( + new MethodCall("runJavascriptReturningResult", "Test JavaScript String"), mockResult); + + // Verify + verify(mockResult, times(1)).success("Test JavaScript Result"); + } + } + + @Test + public void evaluateJavaScript_shouldReturnNilOnSuccessForNoReturnValue() { + try (MockedStatic mockedFlutterWebView = mockStatic(FlutterWebView.class)) { + // Setup + mockedFlutterWebView + .when( + () -> + FlutterWebView.createWebView( + (WebViewBuilder) any(), + (Map) any(), + (WebChromeClient) any(), + (DownloadListener) any())) + .thenReturn(mockWebView); + doAnswer( + invocation -> { + android.webkit.ValueCallback callback = invocation.getArgument(1); + callback.onReceiveValue("Test JavaScript Result"); + return null; + }) + .when(mockWebView) + .evaluateJavascript(eq("Test JavaScript String"), any()); + FlutterWebView flutterWebView = + new FlutterWebView(mockContext, mockMethodChannel, new HashMap(), null); + + // Run + flutterWebView.onMethodCall( + new MethodCall("runJavascript", "Test JavaScript String"), mockResult); + + // Verify + verify(mockResult, times(1)).success(isNull()); + } + } + + private Map createParameterMap(boolean usesHybridComposition) { + Map params = new HashMap<>(); + params.put("usesHybridComposition", usesHybridComposition); + + return params; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/JavaScriptChannelTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/JavaScriptChannelTest.java new file mode 100644 index 000000000000..697ea0b70b90 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/JavaScriptChannelTest.java @@ -0,0 +1,60 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import android.os.Handler; +import io.flutter.plugins.webviewflutter.GeneratedAndroidWebView.JavaScriptChannelFlutterApi; +import io.flutter.plugins.webviewflutter.JavaScriptChannelHostApiImpl.JavaScriptChannelCreator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +public class JavaScriptChannelTest { + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock public GeneratedAndroidWebView.JavaScriptChannelFlutterApi mockFlutterApi; + + InstanceManager testInstanceManager; + JavaScriptChannelHostApiImpl testHostApiImpl; + JavaScriptChannel testJavaScriptChannel; + + @Before + public void setUp() { + testInstanceManager = new InstanceManager(); + + final JavaScriptChannelCreator javaScriptChannelCreator = + new JavaScriptChannelCreator() { + @Override + JavaScriptChannel createJavaScriptChannel( + Long instanceId, + JavaScriptChannelFlutterApi javaScriptChannelFlutterApi, + String channelName, + Handler platformThreadHandler) { + testJavaScriptChannel = + super.createJavaScriptChannel( + instanceId, javaScriptChannelFlutterApi, channelName, platformThreadHandler); + return testJavaScriptChannel; + } + }; + + testHostApiImpl = + new JavaScriptChannelHostApiImpl( + testInstanceManager, javaScriptChannelCreator, mockFlutterApi, new Handler()); + testHostApiImpl.create(0L, "aChannelName"); + } + + @Test + public void postMessage() { + testJavaScriptChannel.postMessage("A message post."); + verify(mockFlutterApi).postMessage(eq(0L), eq("A message post."), any()); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebChromeClientTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebChromeClientTest.java new file mode 100644 index 000000000000..5ab3ab10fe03 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebChromeClientTest.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.webviewflutter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import io.flutter.plugins.webviewflutter.GeneratedAndroidWebView.WebChromeClientFlutterApi; +import io.flutter.plugins.webviewflutter.WebChromeClientHostApiImpl.WebChromeClientCreator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +public class WebChromeClientTest { + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock public WebChromeClientFlutterApi mockFlutterApi; + + @Mock public WebView mockWebView; + + @Mock public WebViewClient mockWebViewClient; + + InstanceManager testInstanceManager; + WebChromeClientHostApiImpl testHostApiImpl; + WebChromeClient testWebChromeClient; + + @Before + public void setUp() { + testInstanceManager = new InstanceManager(); + testInstanceManager.addInstance(mockWebView, 0L); + testInstanceManager.addInstance(mockWebViewClient, 1L); + + final WebChromeClientCreator webChromeClientCreator = + new WebChromeClientCreator() { + @Override + WebChromeClient createWebChromeClient( + Long instanceId, + InstanceManager instanceManager, + WebViewClient webViewClient, + WebChromeClientFlutterApi webChromeClientFlutterApi) { + testWebChromeClient = + super.createWebChromeClient( + instanceId, instanceManager, webViewClient, webChromeClientFlutterApi); + return testWebChromeClient; + } + }; + + testHostApiImpl = + new WebChromeClientHostApiImpl(testInstanceManager, webChromeClientCreator, mockFlutterApi); + testHostApiImpl.create(2L, 1L); + } + + @Test + public void onProgressChanged() { + testWebChromeClient.onProgressChanged(mockWebView, 23); + verify(mockFlutterApi).onProgressChanged(eq(2L), eq(0L), eq(23L), any()); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebSettingsTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebSettingsTest.java new file mode 100644 index 000000000000..8ef32ddcb4ca --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebSettingsTest.java @@ -0,0 +1,103 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.webkit.WebSettings; +import io.flutter.plugins.webviewflutter.WebSettingsHostApiImpl.WebSettingsCreator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +public class WebSettingsTest { + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock public WebSettings mockWebSettings; + + @Mock WebSettingsCreator mockWebSettingsCreator; + + InstanceManager testInstanceManager; + WebSettingsHostApiImpl testHostApiImpl; + + @Before + public void setUp() { + testInstanceManager = new InstanceManager(); + when(mockWebSettingsCreator.createWebSettings(any())).thenReturn(mockWebSettings); + testHostApiImpl = new WebSettingsHostApiImpl(testInstanceManager, mockWebSettingsCreator); + testHostApiImpl.create(0L, 0L); + } + + @Test + public void setDomStorageEnabled() { + testHostApiImpl.setDomStorageEnabled(0L, true); + verify(mockWebSettings).setDomStorageEnabled(true); + } + + @Test + public void setJavaScriptCanOpenWindowsAutomatically() { + testHostApiImpl.setJavaScriptCanOpenWindowsAutomatically(0L, false); + verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(false); + } + + @Test + public void setSupportMultipleWindows() { + testHostApiImpl.setSupportMultipleWindows(0L, true); + verify(mockWebSettings).setSupportMultipleWindows(true); + } + + @Test + public void setJavaScriptEnabled() { + testHostApiImpl.setJavaScriptEnabled(0L, false); + verify(mockWebSettings).setJavaScriptEnabled(false); + } + + @Test + public void setUserAgentString() { + testHostApiImpl.setUserAgentString(0L, "hello"); + verify(mockWebSettings).setUserAgentString("hello"); + } + + @Test + public void setMediaPlaybackRequiresUserGesture() { + testHostApiImpl.setMediaPlaybackRequiresUserGesture(0L, false); + verify(mockWebSettings).setMediaPlaybackRequiresUserGesture(false); + } + + @Test + public void setSupportZoom() { + testHostApiImpl.setSupportZoom(0L, true); + verify(mockWebSettings).setSupportZoom(true); + } + + @Test + public void setLoadWithOverviewMode() { + testHostApiImpl.setLoadWithOverviewMode(0L, false); + verify(mockWebSettings).setLoadWithOverviewMode(false); + } + + @Test + public void setUseWideViewPort() { + testHostApiImpl.setUseWideViewPort(0L, true); + verify(mockWebSettings).setUseWideViewPort(true); + } + + @Test + public void setDisplayZoomControls() { + testHostApiImpl.setDisplayZoomControls(0L, false); + verify(mockWebSettings).setDisplayZoomControls(false); + } + + @Test + public void setBuiltInZoomControls() { + testHostApiImpl.setBuiltInZoomControls(0L, true); + verify(mockWebSettings).setBuiltInZoomControls(true); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java new file mode 100644 index 000000000000..423cb210c392 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewBuilderTest.java @@ -0,0 +1,104 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.*; + +import android.content.Context; +import android.view.View; +import android.webkit.DownloadListener; +import android.webkit.WebChromeClient; +import android.webkit.WebSettings; +import android.webkit.WebView; +import io.flutter.plugins.webviewflutter.WebViewBuilder.WebViewFactory; +import java.io.IOException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.MockedStatic.Verification; + +public class WebViewBuilderTest { + private Context mockContext; + private View mockContainerView; + private WebView mockWebView; + private MockedStatic mockedStaticWebViewFactory; + + @Before + public void before() { + mockContext = mock(Context.class); + mockContainerView = mock(View.class); + mockWebView = mock(WebView.class); + mockedStaticWebViewFactory = mockStatic(WebViewFactory.class); + + mockedStaticWebViewFactory + .when( + new Verification() { + @Override + public void apply() { + WebViewFactory.create(mockContext, false, mockContainerView); + } + }) + .thenReturn(mockWebView); + } + + @After + public void after() { + mockedStaticWebViewFactory.close(); + } + + @Test + public void ctor_test() { + WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); + + assertNotNull(builder); + } + + @Test + public void build_should_set_values() throws IOException { + WebSettings mockWebSettings = mock(WebSettings.class); + WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); + DownloadListener mockDownloadListener = mock(DownloadListener.class); + + when(mockWebView.getSettings()).thenReturn(mockWebSettings); + + WebViewBuilder builder = + new WebViewBuilder(mockContext, mockContainerView) + .setDomStorageEnabled(true) + .setJavaScriptCanOpenWindowsAutomatically(true) + .setSupportMultipleWindows(true) + .setWebChromeClient(mockWebChromeClient) + .setDownloadListener(mockDownloadListener); + + WebView webView = builder.build(); + + assertNotNull(webView); + verify(mockWebSettings).setDomStorageEnabled(true); + verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(true); + verify(mockWebSettings).setSupportMultipleWindows(true); + verify(mockWebView).setWebChromeClient(mockWebChromeClient); + verify(mockWebView).setDownloadListener(mockDownloadListener); + } + + @Test + public void build_should_use_default_values() throws IOException { + WebSettings mockWebSettings = mock(WebSettings.class); + WebChromeClient mockWebChromeClient = mock(WebChromeClient.class); + + when(mockWebView.getSettings()).thenReturn(mockWebSettings); + + WebViewBuilder builder = new WebViewBuilder(mockContext, mockContainerView); + + WebView webView = builder.build(); + + assertNotNull(webView); + verify(mockWebSettings).setDomStorageEnabled(false); + verify(mockWebSettings).setJavaScriptCanOpenWindowsAutomatically(false); + verify(mockWebSettings).setSupportMultipleWindows(false); + verify(mockWebView).setWebChromeClient(null); + verify(mockWebView).setDownloadListener(null); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewClientTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewClientTest.java new file mode 100644 index 000000000000..f6d2054564e2 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewClientTest.java @@ -0,0 +1,77 @@ +// Copyright 2013 The Flutter 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.eq; +import static org.mockito.Mockito.verify; + +import android.webkit.WebView; +import android.webkit.WebViewClient; +import io.flutter.plugins.webviewflutter.GeneratedAndroidWebView.WebViewClientFlutterApi; +import io.flutter.plugins.webviewflutter.WebViewClientHostApiImpl.WebViewClientCreator; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +public class WebViewClientTest { + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock public WebViewClientFlutterApi mockFlutterApi; + + @Mock public WebView mockWebView; + + InstanceManager testInstanceManager; + WebViewClientHostApiImpl testHostApiImpl; + WebViewClient testWebViewClient; + + @Before + public void setUp() { + testInstanceManager = new InstanceManager(); + testInstanceManager.addInstance(mockWebView, 0L); + + final WebViewClientCreator webViewClientCreator = + new WebViewClientCreator() { + @Override + WebViewClient createWebViewClient( + Long instanceId, + InstanceManager instanceManager, + Boolean shouldOverrideUrlLoading, + WebViewClientFlutterApi webViewClientFlutterApi) { + testWebViewClient = + super.createWebViewClient( + instanceId, instanceManager, shouldOverrideUrlLoading, webViewClientFlutterApi); + return testWebViewClient; + } + }; + + testHostApiImpl = + new WebViewClientHostApiImpl(testInstanceManager, webViewClientCreator, mockFlutterApi); + testHostApiImpl.create(1L, true); + } + + @Test + public void onPageStarted() { + testWebViewClient.onPageStarted(mockWebView, "https://www.google.com", null); + verify(mockFlutterApi).onPageStarted(eq(1L), eq(0L), eq("https://www.google.com"), any()); + } + + @Test + public void onReceivedError() { + testWebViewClient.onReceivedError(mockWebView, 32, "description", "https://www.google.com"); + verify(mockFlutterApi) + .onReceivedError( + eq(1L), eq(0L), eq(32L), eq("description"), eq("https://www.google.com"), any()); + } + + @Test + public void urlLoading() { + testWebViewClient.shouldOverrideUrlLoading(mockWebView, "https://www.google.com"); + verify(mockFlutterApi).urlLoading(eq(1L), eq(0L), eq("https://www.google.com"), any()); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java new file mode 100644 index 000000000000..b914ce913e76 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/android/src/test/java/io/flutter/plugins/webviewflutter/WebViewTest.java @@ -0,0 +1,222 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutter; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.webkit.DownloadListener; +import android.webkit.ValueCallback; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import java.util.HashMap; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +public class WebViewTest { + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock public WebView mockWebView; + + @Mock WebViewHostApiImpl.WebViewProxy mockWebViewProxy; + + @Mock Context mockContext; + + InstanceManager testInstanceManager; + WebViewHostApiImpl testHostApiImpl; + + @Before + public void setUp() { + testInstanceManager = new InstanceManager(); + when(mockWebViewProxy.createWebView(mockContext)).thenReturn(mockWebView); + testHostApiImpl = new WebViewHostApiImpl(testInstanceManager, mockWebViewProxy, mockContext); + testHostApiImpl.create(0L, true); + } + + @Test + public void errorCodes() { + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_AUTHENTICATION), + "authentication"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_BAD_URL), "badUrl"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_CONNECT), "connect"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FAILED_SSL_HANDSHAKE), + "failedSslHandshake"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE), "file"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_FILE_NOT_FOUND), "fileNotFound"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_HOST_LOOKUP), "hostLookup"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_IO), "io"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_PROXY_AUTHENTICATION), + "proxyAuthentication"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_REDIRECT_LOOP), "redirectLoop"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TIMEOUT), "timeout"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_TOO_MANY_REQUESTS), + "tooManyRequests"); + assertEquals(FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNKNOWN), "unknown"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSAFE_RESOURCE), + "unsafeResource"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_AUTH_SCHEME), + "unsupportedAuthScheme"); + assertEquals( + FlutterWebViewClient.errorCodeToString(WebViewClient.ERROR_UNSUPPORTED_SCHEME), + "unsupportedScheme"); + } + + @Test + public void loadUrl() { + testHostApiImpl.loadUrl(0L, "https://www.google.com", new HashMap<>()); + verify(mockWebView).loadUrl("https://www.google.com", new HashMap<>()); + } + + @Test + public void getUrl() { + when(mockWebView.getUrl()).thenReturn("https://www.google.com"); + assertEquals(testHostApiImpl.getUrl(0L), "https://www.google.com"); + } + + @Test + public void canGoBack() { + when(mockWebView.canGoBack()).thenReturn(true); + assertEquals(testHostApiImpl.canGoBack(0L), true); + } + + @Test + public void canGoForward() { + when(mockWebView.canGoForward()).thenReturn(false); + assertEquals(testHostApiImpl.canGoForward(0L), false); + } + + @Test + public void goBack() { + testHostApiImpl.goBack(0L); + verify(mockWebView).goBack(); + } + + @Test + public void goForward() { + testHostApiImpl.goForward(0L); + verify(mockWebView).goForward(); + } + + @Test + public void reload() { + testHostApiImpl.reload(0L); + verify(mockWebView).reload(); + } + + @Test + public void clearCache() { + testHostApiImpl.clearCache(0L, false); + verify(mockWebView).clearCache(false); + } + + @Test + public void evaluateJavaScript() { + final String[] successValue = new String[1]; + testHostApiImpl.evaluateJavascript( + 0L, + "2 + 2", + new GeneratedAndroidWebView.Result() { + @Override + public void success(String result) { + successValue[0] = result; + } + + @Override + public void error(Throwable error) {} + }); + + @SuppressWarnings("unchecked") + final ArgumentCaptor> callbackCaptor = + ArgumentCaptor.forClass(ValueCallback.class); + verify(mockWebView).evaluateJavascript(eq("2 + 2"), callbackCaptor.capture()); + + callbackCaptor.getValue().onReceiveValue("da result"); + assertEquals(successValue[0], "da result"); + } + + @Test + public void getTitle() { + when(mockWebView.getTitle()).thenReturn("My title"); + assertEquals(testHostApiImpl.getTitle(0L), "My title"); + } + + @Test + public void scrollTo() { + testHostApiImpl.scrollTo(0L, 12L, 13L); + verify(mockWebView).scrollTo(12, 13); + } + + @Test + public void scrollBy() { + testHostApiImpl.scrollBy(0L, 15L, 23L); + verify(mockWebView).scrollBy(15, 23); + } + + @Test + public void getScrollX() { + when(mockWebView.getScrollX()).thenReturn(55); + assertEquals((long) testHostApiImpl.getScrollX(0L), 55); + } + + @Test + public void getScrollY() { + when(mockWebView.getScrollY()).thenReturn(23); + assertEquals((long) testHostApiImpl.getScrollY(0L), 23); + } + + @Test + public void setWebViewClient() { + final WebViewClient mockWebViewClient = mock(WebViewClient.class); + testInstanceManager.addInstance(mockWebViewClient, 1L); + + testHostApiImpl.setWebViewClient(0L, 1L); + verify(mockWebView).setWebViewClient(mockWebViewClient); + } + + @Test + public void addJavaScriptChannel() { + final JavaScriptChannel javaScriptChannel = new JavaScriptChannel(null, "aName", null); + testInstanceManager.addInstance(javaScriptChannel, 1L); + + testHostApiImpl.addJavaScriptChannel(0L, 1L); + verify(mockWebView).addJavascriptInterface(javaScriptChannel, "aName"); + } + + @Test + public void removeJavaScriptChannel() { + final JavaScriptChannel javaScriptChannel = new JavaScriptChannel(null, "aName", null); + testInstanceManager.addInstance(javaScriptChannel, 1L); + + testHostApiImpl.removeJavaScriptChannel(0L, 1L); + verify(mockWebView).removeJavascriptInterface("aName"); + } + + @Test + public void setDownloadListener() { + final DownloadListener mockDownloadListener = mock(DownloadListener.class); + testInstanceManager.addInstance(mockDownloadListener, 1L); + + testHostApiImpl.setDownloadListener(0L, 1L); + verify(mockWebView).setDownloadListener(mockDownloadListener); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/.metadata b/packages/webview_flutter/webview_flutter_android/example/.metadata new file mode 100644 index 000000000000..da83b1ada1bd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/.metadata @@ -0,0 +1,8 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 1e5cb2d87f8542f9fbbd0f22d528823274be0acb + channel: master diff --git a/packages/webview_flutter/webview_flutter_android/example/README.md b/packages/webview_flutter/webview_flutter_android/example/README.md new file mode 100644 index 000000000000..850ee74397a9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/README.md @@ -0,0 +1,8 @@ +# webview_flutter_example + +Demonstrates how to use the webview_flutter plugin. + +## Getting Started + +For help getting started with Flutter, view our online +[documentation](https://flutter.dev/). diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/build.gradle b/packages/webview_flutter/webview_flutter_android/example/android/app/build.gradle new file mode 100644 index 000000000000..1dcd363c9a44 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/build.gradle @@ -0,0 +1,62 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "io.flutter.plugins.webviewflutterandroidexample" + minSdkVersion 19 + targetSdkVersion 28 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + api 'androidx.test:core:1.2.0' +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter_android/example/android/app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..9a4163a4f5ee --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java new file mode 100644 index 000000000000..0f4298dca155 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/DartIntegrationTest.java @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface DartIntegrationTest {} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java new file mode 100644 index 000000000000..a32aaebb0ecd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/MainActivityTest.java @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutterexample; + +import androidx.test.rule.ActivityTestRule; +import dev.flutter.plugins.integration_test.FlutterTestRunner; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.plugins.DartIntegrationTest; +import org.junit.Rule; +import org.junit.runner.RunWith; + +@DartIntegrationTest +@RunWith(FlutterTestRunner.class) +public class MainActivityTest { + @Rule + public ActivityTestRule rule = new ActivityTestRule<>(FlutterActivity.class); +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java new file mode 100644 index 000000000000..0b3eeef9b6b7 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/androidTest/java/io/flutter/plugins/webviewflutterexample/WebViewTest.java @@ -0,0 +1,23 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutterexample; + +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ActivityScenario; +import io.flutter.plugins.webviewflutter.WebViewFlutterPlugin; +import org.junit.Test; + +public class WebViewTest { + @Test + public void webViewPluginIsAdded() { + final ActivityScenario scenario = + ActivityScenario.launch(WebViewTestActivity.class); + scenario.onActivity( + activity -> { + assertTrue(activity.engine.getPlugins().has(WebViewFlutterPlugin.class)); + }); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/debug/AndroidManifest.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..28792201bc36 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/AndroidManifest.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..b8c8d38d45a5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java new file mode 100644 index 000000000000..cb53a7a0dbf5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/java/io/flutter/plugins/webviewflutterexample/WebViewTestActivity.java @@ -0,0 +1,20 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.webviewflutterexample; + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; + +// Extends FlutterActivity to make the FlutterEngine accessible for testing. +public class WebViewTestActivity extends FlutterActivity { + public FlutterEngine engine; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + engine = flutterEngine; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/drawable/launch_background.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000000..304732f88420 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000000..db77bb4b7b09 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000000..17987b79bb8a Binary files /dev/null and b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000000..09d4391482be Binary files /dev/null and b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000000..d5f1c8d34e7a Binary files /dev/null and b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000000..4d6372eebdb2 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/values/styles.xml b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..00fa4417cfbe --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ + + + + diff --git a/packages/webview_flutter/webview_flutter_android/example/android/build.gradle b/packages/webview_flutter/webview_flutter_android/example/android/build.gradle new file mode 100644 index 000000000000..e101ac08df55 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/webview_flutter/webview_flutter_android/example/android/gradle.properties b/packages/webview_flutter/webview_flutter_android/example/android/gradle.properties new file mode 100644 index 000000000000..a6738207fd15 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/packages/webview_flutter/webview_flutter_android/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/webview_flutter/webview_flutter_android/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..2819f022f1fd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip diff --git a/packages/webview_flutter/webview_flutter_android/example/android/settings.gradle b/packages/webview_flutter/webview_flutter_android/example/android/settings.gradle new file mode 100644 index 000000000000..5a2f14fb18f6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/packages/webview_flutter/webview_flutter_android/example/assets/sample_audio.ogg b/packages/webview_flutter/webview_flutter_android/example/assets/sample_audio.ogg new file mode 100644 index 000000000000..27e17104277b Binary files /dev/null and b/packages/webview_flutter/webview_flutter_android/example/assets/sample_audio.ogg differ diff --git a/packages/webview_flutter/webview_flutter_android/example/assets/sample_video.mp4 b/packages/webview_flutter/webview_flutter_android/example/assets/sample_video.mp4 new file mode 100644 index 000000000000..a203d0cdf13e Binary files /dev/null and b/packages/webview_flutter/webview_flutter_android/example/assets/sample_video.mp4 differ diff --git a/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart new file mode 100644 index 000000000000..8e3dcb458ee8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/integration_test/webview_flutter_test.dart @@ -0,0 +1,1442 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:webview_flutter_android/webview_android.dart'; +import 'package:webview_flutter_android/webview_surface_android.dart'; +import 'package:webview_flutter_android_example/navigation_decision.dart'; +import 'package:webview_flutter_android_example/navigation_request.dart'; +import 'package:webview_flutter_android_example/web_view.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // URLs to navigate to in tests. These need to be URLs that we are confident will + // always be accessible, and won't do redirection. (E.g., just + // 'https://www.google.com/' will sometimes redirect traffic that looks + // like it's coming from a bot, which is true of these tests). + const String primaryUrl = 'https://flutter.dev/'; + const String secondaryUrl = 'https://www.google.com/robots.txt'; + + const bool _skipDueToIssue86757 = true; + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('initialUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.loadUrl(secondaryUrl); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }, skip: _skipDueToIssue86757); + + testWidgets('evaluateJavascript', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String result = await controller.evaluateJavascript('1 + 1'); + expect(result, equals('2')); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl with headers', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageStarts = StreamController(); + final StreamController pageLoads = StreamController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarts.add(url); + }, + onPageFinished: (String url) { + pageLoads.add(url); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final Map headers = { + 'test_header': 'flutter_test_header' + }; + await controller.loadUrl('https://flutter-header-echo.herokuapp.com/', + headers: headers); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter-header-echo.herokuapp.com/'); + + await pageStarts.stream.firstWhere((String url) => url == currentUrl); + await pageLoads.stream.firstWhere((String url) => url == currentUrl); + + final String content = await controller + .runJavascriptReturningResult('document.documentElement.innerText'); + expect(content.contains('flutter_test_header'), isTrue); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('JavascriptChannel', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final List messagesReceived = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + // This is the data URL for: '' + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'Echo', + onMessageReceived: (JavascriptMessage message) { + messagesReceived.add(message.message); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(messagesReceived, isEmpty); + await controller.runJavascript('Echo.postMessage("hello");'); + expect(messagesReceived, equals(['hello'])); + }, skip: _skipDueToIssue86757); + + testWidgets('resize webview', (WidgetTester tester) async { + final String resizeTest = ''' + + Resize test + + + + + + '''; + final String resizeTestBase64 = + base64Encode(const Utf8Encoder().convert(resizeTest)); + final Completer resizeCompleter = Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + final GlobalKey key = GlobalKey(); + + final WebView webView = WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$resizeTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptChannels: { + JavascriptChannel( + name: 'Resize', + onMessageReceived: (JavascriptMessage message) { + resizeCompleter.complete(true); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 200, + height: 200, + child: webView, + ), + ], + ), + ), + ); + + await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(resizeCompleter.isCompleted, false); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 400, + height: 400, + child: webView, + ), + ], + ), + ), + ); + + await resizeCompleter.future; + }); + + testWidgets('set custom userAgent', (WidgetTester tester) async { + final Completer controllerCompleter1 = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent1', + onWebViewCreated: (WebViewController controller) { + controllerCompleter1.complete(controller); + }, + ), + ), + ); + final WebViewController controller1 = await controllerCompleter1.future; + final String customUserAgent1 = await _getUserAgent(controller1); + expect(customUserAgent1, 'Custom_User_Agent1'); + // rebuild the WebView with a different user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent2', + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller1); + expect(customUserAgent2, 'Custom_User_Agent2'); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('use default platform userAgent after webView is rebuilt', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + // Build the webView with no user agent to get the default platform user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: primaryUrl, + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String defaultPlatformUserAgent = await _getUserAgent(controller); + // rebuild the WebView with a custom user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ), + ), + ); + final String customUserAgent = await _getUserAgent(controller); + expect(customUserAgent, 'Custom_User_Agent'); + // rebuilds the WebView with no user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller); + expect(customUserAgent2, defaultPlatformUserAgent); + }, skip: _skipDueToIssue86757); + + group('Video playback policy', () { + late String videoTestBase64; + setUpAll(() async { + final ByteData videoData = + await rootBundle.load('assets/sample_video.mp4'); + final String base64VideoData = + base64Encode(Uint8List.view(videoData.buffer)); + final String videoTest = ''' + + Video auto play + + + + + + + '''; + videoTestBase64 = base64Encode(const Utf8Encoder().convert(videoTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolicy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + + testWidgets('Video plays inline when allowsInlineMediaPlayback is true', + (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: true, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.runJavascriptReturningResult('isFullScreen();'); + expect(fullScreen, _webviewBool(false)); + }); + }); + + group('Audio playback policy', () { + late String audioTestBase64; + setUpAll(() async { + final ByteData audioData = + await rootBundle.load('assets/sample_audio.ogg'); + final String base64AudioData = + base64Encode(Uint8List.view(audioData.buffer)); + final String audioTest = ''' + + Audio auto play + + + + + + + '''; + audioTestBase64 = base64Encode(const Utf8Encoder().convert(audioTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageStarted = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolocy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageStarted = Completer(); + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + }); + + testWidgets('getTitle', (WidgetTester tester) async { + final String getTitleTest = ''' + + Some title + + + + + '''; + final String getTitleTestBase64 = + base64Encode(const Utf8Encoder().convert(getTitleTest)); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$getTitleTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + final String? title = await controller.getTitle(); + expect(title, 'Some title'); + }); + + group('Programmatic Scroll', () { + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
+ + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + // Get the initial position; this ensures that scrollTo is actually + // changing something, but also gives the native view's scroll position + // time to settle. + expect(scrollPosX, isNot(X_SCROLL)); + expect(scrollPosX, isNot(Y_SCROLL)); + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL); + expect(scrollPosY, Y_SCROLL); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL * 2); + expect(scrollPosY, Y_SCROLL * 2); + }, skip: _skipDueToIssue86757); + }); + + group('SurfaceAndroidWebView', () { + setUpAll(() { + WebView.platform = SurfaceAndroidWebView(); + }); + + tearDownAll(() { + WebView.platform = AndroidWebView(); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
+ + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + expect(X_SCROLL, scrollPosX); + expect(Y_SCROLL, scrollPosY); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(X_SCROLL * 2, scrollPosX); + expect(Y_SCROLL * 2, scrollPosY); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('inputs are scrolled into view when focused', + (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
+ + + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.runAsync(() async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 200, + height: 200, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ), + ); + await Future.delayed(Duration(milliseconds: 20)); + await tester.pump(); + }); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + final String viewportRectJSON = await _runJavaScriptReturningResult( + controller, 'JSON.stringify(viewport.getBoundingClientRect())'); + final Map viewportRectRelativeToViewport = + jsonDecode(viewportRectJSON); + + // Check that the input is originally outside of the viewport. + + final String initialInputClientRectJSON = + await _runJavaScriptReturningResult( + controller, 'JSON.stringify(inputEl.getBoundingClientRect())'); + final Map initialInputClientRectRelativeToViewport = + jsonDecode(initialInputClientRectJSON); + + expect( + initialInputClientRectRelativeToViewport['bottom'] <= + viewportRectRelativeToViewport['bottom'], + isFalse); + + await controller.runJavascript('inputEl.focus()'); + + // Check that focusing the input brought it into view. + + final String lastInputClientRectJSON = + await _runJavaScriptReturningResult( + controller, 'JSON.stringify(inputEl.getBoundingClientRect())'); + final Map lastInputClientRectRelativeToViewport = + jsonDecode(lastInputClientRectJSON); + + expect( + lastInputClientRectRelativeToViewport['top'] >= + viewportRectRelativeToViewport['top'], + isTrue); + expect( + lastInputClientRectRelativeToViewport['bottom'] <= + viewportRectRelativeToViewport['bottom'], + isTrue); + + expect( + lastInputClientRectRelativeToViewport['left'] >= + viewportRectRelativeToViewport['left'], + isTrue); + expect( + lastInputClientRectRelativeToViewport['right'] <= + viewportRectRelativeToViewport['right'], + isTrue); + }, skip: _skipDueToIssue86757); + }); + + group('NavigationDelegate', () { + final String blankPage = ""; + final String blankPageEncoded = 'data:text/html;charset=utf-8;base64,' + + base64Encode(const Utf8Encoder().convert(blankPage)); + + testWidgets('can allow requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('location.href = "$secondaryUrl"'); + + await pageLoads.stream.first; // Wait for the next page load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }); + + testWidgets('onWebResourceError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://www.notawebsite..com', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + final WebResourceError error = await errorCompleter.future; + expect(error, isNotNull); + + expect(error.errorType, isNotNull); + expect( + error.failingUrl?.startsWith('https://www.notawebsite..com'), isTrue); + }); + + testWidgets('onWebResourceError is not called with valid url', + (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + + testWidgets( + 'onWebResourceError only called for main frame', + (WidgetTester tester) async { + final String iframeTest = ''' + + + + WebResourceError test + + + + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframeTest)); + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,$iframeTestBase64', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }, + ); + + testWidgets('can block requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .runJavascript('location.href = "https://www.youtube.com/"'); + + // There should never be any second page load, since our new URL is + // blocked. Still wait for a potential page change for some time in order + // to give the test a chance to fail. + await pageLoads.stream.first + .timeout(const Duration(milliseconds: 500), onTimeout: () => ''); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, isNot(contains('youtube.com'))); + }); + + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate); + return decision; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('location.href = "$secondaryUrl"'); + + await pageLoads.stream.first; // Wait for second page to load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }); + }); + + testWidgets('launches with gestureNavigationEnabled on iOS', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 300, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + gestureNavigationEnabled: true, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }); + + testWidgets('target _blank opens in same window', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('window.open("$primaryUrl", "_blank")'); + await pageLoaded.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }, + // Flaky on Android: https://github.com/flutter/flutter/issues/86757 + skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets( + 'can open new window and go back', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(); + }, + initialUrl: primaryUrl, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + expect(controller.currentUrl(), completion(primaryUrl)); + await pageLoaded.future; + pageLoaded = Completer(); + + await controller.runJavascript('window.open("$secondaryUrl")'); + await pageLoaded.future; + pageLoaded = Completer(); + expect(controller.currentUrl(), completion(secondaryUrl)); + + expect(controller.canGoBack(), completion(true)); + await controller.goBack(); + await pageLoaded.future; + expect(controller.currentUrl(), completion(primaryUrl)); + }, + skip: _skipDueToIssue86757, + ); + + testWidgets( + 'JavaScript does not run in parent window', + (WidgetTester tester) async { + final String iframe = ''' + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframe)); + + final String openWindowTest = ''' + + + + XSS test + + + + + + '''; + final String openWindowTestBase64 = + base64Encode(const Utf8Encoder().convert(openWindowTest)); + final Completer controllerCompleter = + Completer(); + final Completer pageLoadCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + initialUrl: + 'data:text/html;charset=utf-8;base64,$openWindowTestBase64', + onPageFinished: (String url) { + pageLoadCompleter.complete(); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoadCompleter.future; + + expect(controller.runJavascriptReturningResult('iframeLoaded'), + completion('true')); + expect( + controller.runJavascriptReturningResult( + 'document.querySelector("p") && document.querySelector("p").textContent'), + completion('null'), + ); + }, + ); +} + +// JavaScript booleans evaluate to different string values on Android and iOS. +// This utility method returns the string boolean value of the current platform. +String _webviewBool(bool value) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return value ? '1' : '0'; + } + return value ? 'true' : 'false'; +} + +/// Returns the value used for the HTTP User-Agent: request header in subsequent HTTP requests. +Future _getUserAgent(WebViewController controller) async { + return _runJavaScriptReturningResult(controller, 'navigator.userAgent;'); +} + +Future _runJavaScriptReturningResult( + WebViewController controller, String js) async { + return jsonDecode(await controller.runJavascriptReturningResult(js)); +} diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/main.dart b/packages/webview_flutter/webview_flutter_android/example/lib/main.dart new file mode 100644 index 000000000000..a22d165a6ff3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/main.dart @@ -0,0 +1,344 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter_android/webview_surface_android.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'web_view.dart'; + +void main() { + // Configure the [WebView] to use the [SurfaceAndroidWebView] + // implementation instead of the default [AndroidWebView]. + WebView.platform = SurfaceAndroidWebView(); + + runApp(MaterialApp(home: _WebViewExample())); +} + +const String kNavigationExamplePage = ''' + +Navigation Delegate Example + +

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

+ + + +'''; + +class _WebViewExample extends StatefulWidget { + const _WebViewExample({Key? key}) : super(key: key); + + @override + _WebViewExampleState createState() => _WebViewExampleState(); +} + +class _WebViewExampleState extends State<_WebViewExample> { + final Completer _controller = + Completer(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter WebView example'), + // This drop down menu demonstrates that Flutter widgets can be shown over the web view. + actions: [ + _NavigationControls(_controller.future), + _SampleMenu(_controller.future), + ], + ), + // We're using a Builder here so we have a context that is below the Scaffold + // to allow calling Scaffold.of(context) so we can show a snackbar. + body: Builder(builder: (context) { + return WebView( + initialUrl: 'https://flutter.dev', + onWebViewCreated: (WebViewController controller) { + _controller.complete(controller); + }, + javascriptChannels: _createJavascriptChannels(context), + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ); + }), + floatingActionButton: favoriteButton(), + ); + } + + Widget favoriteButton() { + return FutureBuilder( + future: _controller.future, + builder: (BuildContext context, + AsyncSnapshot controller) { + if (controller.hasData) { + return FloatingActionButton( + onPressed: () async { + final String url = (await controller.data!.currentUrl())!; + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar(content: Text('Favorited $url')), + ); + }, + child: const Icon(Icons.favorite), + ); + } + return Container(); + }); + } +} + +Set _createJavascriptChannels(BuildContext context) { + return { + JavascriptChannel( + name: 'Snackbar', + onMessageReceived: (JavascriptMessage message) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message.message))); + }), + }; +} + +enum _MenuOptions { + showUserAgent, + listCookies, + clearCookies, + addToCache, + listCache, + clearCache, + navigationDelegate, +} + +class _SampleMenu extends StatelessWidget { + _SampleMenu(this.controller); + + final Future controller; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: controller, + builder: + (BuildContext context, AsyncSnapshot controller) { + return PopupMenuButton<_MenuOptions>( + onSelected: (_MenuOptions value) { + switch (value) { + case _MenuOptions.showUserAgent: + _onShowUserAgent(controller.data!, context); + break; + case _MenuOptions.listCookies: + _onListCookies(controller.data!, context); + break; + case _MenuOptions.clearCookies: + _onClearCookies(controller.data!, context); + break; + case _MenuOptions.addToCache: + _onAddToCache(controller.data!, context); + break; + case _MenuOptions.listCache: + _onListCache(controller.data!, context); + break; + case _MenuOptions.clearCache: + _onClearCache(controller.data!, context); + break; + case _MenuOptions.navigationDelegate: + _onNavigationDelegateExample(controller.data!, context); + break; + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem<_MenuOptions>( + value: _MenuOptions.showUserAgent, + child: const Text('Show user agent'), + enabled: controller.hasData, + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCookies, + child: Text('List cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCookies, + child: Text('Clear cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.addToCache, + child: Text('Add to cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCache, + child: Text('List cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCache, + child: Text('Clear cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.navigationDelegate, + child: Text('Navigation Delegate example'), + ), + ], + ); + }, + ); + } + + void _onShowUserAgent( + WebViewController controller, BuildContext context) async { + // Send a message with the user agent string to the Snackbar JavaScript channel we registered + // with the WebView. + await controller.runJavascript( + 'Snackbar.postMessage("User Agent: " + navigator.userAgent);'); + } + + void _onListCookies( + WebViewController controller, BuildContext context) async { + final String cookies = + await controller.runJavascriptReturningResult('document.cookie'); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(SnackBar( + content: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Cookies:'), + _getCookieList(cookies), + ], + ), + )); + } + + void _onAddToCache(WebViewController controller, BuildContext context) async { + await controller.runJavascript( + 'caches.open("test_caches_entry"); localStorage["test_localStorage"] = "dummy_entry";'); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(const SnackBar( + content: Text('Added a test entry to cache.'), + )); + } + + void _onListCache(WebViewController controller, BuildContext context) async { + await controller.runJavascript('caches.keys()' + '.then((cacheKeys) => JSON.stringify({"cacheKeys" : cacheKeys, "localStorage" : localStorage}))' + '.then((caches) => Snackbar.postMessage(caches))'); + } + + void _onClearCache(WebViewController controller, BuildContext context) async { + await controller.clearCache(); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(const SnackBar( + content: Text("Cache cleared."), + )); + } + + void _onClearCookies( + WebViewController controller, BuildContext context) async { + final bool hadCookies = await WebView.platform.clearCookies(); + String message = 'There were cookies. Now, they are gone!'; + if (!hadCookies) { + message = 'There are no cookies.'; + } + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar(SnackBar( + content: Text(message), + )); + } + + void _onNavigationDelegateExample( + WebViewController controller, BuildContext context) async { + final String contentBase64 = + base64Encode(const Utf8Encoder().convert(kNavigationExamplePage)); + await controller.loadUrl('data:text/html;base64,$contentBase64'); + } + + Widget _getCookieList(String cookies) { + if (cookies == null || cookies == '""') { + return Container(); + } + final List cookieList = cookies.split(';'); + final Iterable cookieWidgets = + cookieList.map((String cookie) => Text(cookie)); + return Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: cookieWidgets.toList(), + ); + } +} + +class _NavigationControls extends StatelessWidget { + const _NavigationControls(this._webViewControllerFuture) + : assert(_webViewControllerFuture != null); + + final Future _webViewControllerFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _webViewControllerFuture, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + final bool webViewReady = + snapshot.connectionState == ConnectionState.done; + final WebViewController? controller = snapshot.data; + + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller!.canGoBack()) { + await controller.goBack(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar(content: Text("No back history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller!.canGoForward()) { + await controller.goForward(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar( + content: Text("No forward history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.replay), + onPressed: !webViewReady + ? null + : () { + controller!.reload(); + }, + ), + ], + ); + }, + ); + } +} + +/// Callback type for handling messages sent from JavaScript running in a web view. +typedef void JavascriptMessageHandler(JavascriptMessage message); diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/navigation_decision.dart b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_decision.dart new file mode 100644 index 000000000000..d8178acd8096 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_decision.dart @@ -0,0 +1,12 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A decision on how to handle a navigation request. +enum NavigationDecision { + /// Prevent the navigation from taking place. + prevent, + + /// Allow the navigation to take place. + navigate, +} diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/navigation_request.dart b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_request.dart new file mode 100644 index 000000000000..c1ff8dc5a690 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/navigation_request.dart @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Information about a navigation action that is about to be executed. +class NavigationRequest { + NavigationRequest._({required this.url, required this.isForMainFrame}); + + /// The URL that will be loaded if the navigation is executed. + final String url; + + /// Whether the navigation request is to be loaded as the main frame. + final bool isForMainFrame; + + @override + String toString() { + return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/example/lib/web_view.dart b/packages/webview_flutter/webview_flutter_android/example/lib/web_view.dart new file mode 100644 index 000000000000..b43507447462 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/lib/web_view.dart @@ -0,0 +1,645 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter_android/webview_android.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'navigation_decision.dart'; +import 'navigation_request.dart'; + +/// Optional callback invoked when a web view is first created. [controller] is +/// the [WebViewController] for the created web view. +typedef void WebViewCreatedCallback(WebViewController controller); + +/// Decides how to handle a specific navigation request. +/// +/// The returned [NavigationDecision] determines how the navigation described by +/// `navigation` should be handled. +/// +/// See also: [WebView.navigationDelegate]. +typedef FutureOr NavigationDelegate( + NavigationRequest navigation); + +/// Signature for when a [WebView] has started loading a page. +typedef void PageStartedCallback(String url); + +/// Signature for when a [WebView] has finished loading a page. +typedef void PageFinishedCallback(String url); + +/// Signature for when a [WebView] is loading a page. +typedef void PageLoadingCallback(int progress); + +/// Signature for when a [WebView] has failed to load a resource. +typedef void WebResourceErrorCallback(WebResourceError error); + +/// A web view widget for showing html content. +/// +/// The [WebView] widget wraps around the [AndroidWebView] or +/// [SurfaceAndroidWebView] classes and acts like a facade which makes it easier +/// to inject a [AndroidWebView] or [SurfaceAndroidWebView] control into the +/// widget tree. +/// +/// The [WebView] widget is controlled using the [WebViewController] which is +/// provided through the `onWebViewCreated` callback. +/// +/// In this example project it's main purpose is to facilitate integration +/// testing of the `webview_flutter_android` package. +class WebView extends StatefulWidget { + /// Creates a new web view. + /// + /// The web view can be controlled using a `WebViewController` that is passed to the + /// `onWebViewCreated` callback once the web view is created. + /// + /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. + const WebView({ + Key? key, + this.onWebViewCreated, + this.initialUrl, + this.javascriptMode = JavascriptMode.disabled, + this.javascriptChannels, + this.navigationDelegate, + this.gestureRecognizers, + this.onPageStarted, + this.onPageFinished, + this.onProgress, + this.onWebResourceError, + this.debuggingEnabled = false, + this.gestureNavigationEnabled = false, + this.userAgent, + this.zoomEnabled = true, + this.initialMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + this.allowsInlineMediaPlayback = false, + }) : assert(javascriptMode != null), + assert(initialMediaPlaybackPolicy != null), + assert(allowsInlineMediaPlayback != null), + super(key: key); + + static WebViewPlatform _platform = AndroidWebView(); + + /// The WebView platform that's used by this WebView. + /// + /// The default value is [AndroidWebView]. + static WebViewPlatform get platform => _platform; + + /// Sets a custom [WebViewPlatform]. + /// + /// This property can be set to use a custom platform implementation for WebViews. + /// + /// Setting `platform` doesn't affect [WebView]s that were already created. + /// + /// The default value is [AndroidWebView] on Android and [CupertinoWebView] on iOS. + static set platform(WebViewPlatform platform) { + _platform = platform; + } + + /// If not null invoked once the web view is created. + final WebViewCreatedCallback? onWebViewCreated; + + /// Which gestures should be consumed by the web view. + /// + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + /// The initial URL to load. + final String? initialUrl; + + /// Whether JavaScript execution is enabled. + final JavascriptMode javascriptMode; + + /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. + /// + /// For each [JavascriptChannel] in the set, a channel object is made available for the + /// JavaScript code in a window property named [JavascriptChannel.name]. + /// The JavaScript code can then call `postMessage` on that object to send a message that will be + /// passed to [JavascriptChannel.onMessageReceived]. + /// + /// For example for the following [JavascriptChannel]: + /// + /// ```dart + /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); + /// ``` + /// + /// JavaScript code can call: + /// + /// ```javascript + /// Print.postMessage('Hello'); + /// ``` + /// + /// To asynchronously invoke the message handler which will print the message to standard output. + /// + /// Adding a new JavaScript channel only takes affect after the next page is loaded. + /// + /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple + /// channels in the list. + /// + /// A null value is equivalent to an empty set. + final Set? javascriptChannels; + + /// A delegate function that decides how to handle navigation actions. + /// + /// When a navigation is initiated by the WebView (e.g when a user clicks a link) + /// this delegate is called and has to decide how to proceed with the navigation. + /// + /// See [NavigationDecision] for possible decisions the delegate can take. + /// + /// When null all navigation actions are allowed. + /// + /// Caveats on Android: + /// + /// * Navigation actions targeted to the main frame can be intercepted, + /// navigation actions targeted to subframes are allowed regardless of the value + /// returned by this delegate. + /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were + /// triggered by a user gesture, this disables some of Chromium's security mechanisms. + /// A navigationDelegate should only be set when loading trusted content. + /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have + /// a later version): + /// * When a navigationDelegate is set pages with frames are not properly handled by the + /// webview, and frames will be opened in the main frame. + /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. + final NavigationDelegate? navigationDelegate; + + /// Controls whether inline playback of HTML5 videos is allowed on iOS. + /// + /// This field is ignored on Android because Android allows it by default. + /// + /// By default `allowsInlineMediaPlayback` is false. + final bool allowsInlineMediaPlayback; + + /// Invoked when a page starts loading. + final PageStartedCallback? onPageStarted; + + /// Invoked when a page has finished loading. + /// + /// This is invoked only for the main frame. + /// + /// When [onPageFinished] is invoked on Android, the page being rendered may + /// not be updated yet. + /// + /// When invoked on iOS or Android, any JavaScript code that is embedded + /// directly in the HTML has been loaded and code injected with + /// [WebViewController.evaluateJavascript] can assume this. + final PageFinishedCallback? onPageFinished; + + /// Invoked when a page is loading. + final PageLoadingCallback? onProgress; + + /// Invoked when a web resource has failed to load. + /// + /// This callback is only called for the main page. + final WebResourceErrorCallback? onWebResourceError; + + /// Controls whether WebView debugging is enabled. + /// + /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). + /// + /// WebView debugging is enabled by default in dev builds on iOS. + /// + /// To debug WebViews on iOS: + /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) + /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> + /// + /// By default `debuggingEnabled` is false. + final bool debuggingEnabled; + + /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. + /// + /// This only works on iOS. + /// + /// By default `gestureNavigationEnabled` is false. + final bool gestureNavigationEnabled; + + /// A Boolean value indicating whether the WebView should support zooming using its on-screen zoom controls and gestures. + /// + /// By default 'zoomEnabled' is true + final bool zoomEnabled; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + /// + /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. + /// + /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. + /// + /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom + /// user agent. + /// + /// By default `userAgent` is null. + final String? userAgent; + + /// Which restrictions apply on automatic media playback. + /// + /// This initial value is applied to the platform's webview upon creation. Any following + /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). + /// + /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. + final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; + + @override + _WebViewState createState() => _WebViewState(); +} + +class _WebViewState extends State { + final Completer _controller = + Completer(); + late final JavascriptChannelRegistry _javascriptChannelRegistry; + late final _PlatformCallbacksHandler _platformCallbacksHandler; + + @override + void initState() { + super.initState(); + _platformCallbacksHandler = _PlatformCallbacksHandler(widget); + _javascriptChannelRegistry = + JavascriptChannelRegistry(widget.javascriptChannels); + } + + @override + void didUpdateWidget(WebView oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.future.then((WebViewController controller) { + controller.updateWidget(widget); + }); + } + + @override + Widget build(BuildContext context) { + return WebView.platform.build( + context: context, + onWebViewPlatformCreated: + (WebViewPlatformController? webViewPlatformController) { + WebViewController controller = WebViewController( + widget, + webViewPlatformController!, + _javascriptChannelRegistry, + ); + _controller.complete(controller); + + if (widget.onWebViewCreated != null) { + widget.onWebViewCreated!(controller); + } + }, + webViewPlatformCallbacksHandler: _platformCallbacksHandler, + creationParams: CreationParams( + initialUrl: widget.initialUrl, + webSettings: _webSettingsFromWidget(widget), + javascriptChannelNames: + _javascriptChannelRegistry.channels.keys.toSet(), + autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, + userAgent: widget.userAgent, + ), + javascriptChannelRegistry: _javascriptChannelRegistry, + ); + } +} + +class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { + _PlatformCallbacksHandler(this._webView); + + final WebView _webView; + + @override + FutureOr onNavigationRequest({ + required String url, + required bool isForMainFrame, + }) async { + if (url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $url'); + return false; + } + print('allowing navigation to $url'); + return true; + } + + @override + void onPageStarted(String url) { + if (_webView.onPageStarted != null) { + _webView.onPageStarted!(url); + } + } + + @override + void onPageFinished(String url) { + if (_webView.onPageFinished != null) { + _webView.onPageFinished!(url); + } + } + + @override + void onProgress(int progress) { + if (_webView.onProgress != null) { + _webView.onProgress!(progress); + } + } + + void onWebResourceError(WebResourceError error) { + if (_webView.onWebResourceError != null) { + _webView.onWebResourceError!(error); + } + } +} + +/// Controls a [WebView]. +/// +/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] +/// callback for a [WebView] widget. +class WebViewController { + /// Creates a [WebViewController] which can be used to control the provided + /// [WebView] widget. + WebViewController( + this._widget, + this._webViewPlatformController, + this._javascriptChannelRegistry, + ) : assert(_webViewPlatformController != null) { + _settings = _webSettingsFromWidget(_widget); + } + + final JavascriptChannelRegistry _javascriptChannelRegistry; + + final WebViewPlatformController _webViewPlatformController; + + late WebSettings _settings; + + WebView _widget; + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, { + Map? headers, + }) async { + assert(url != null); + _validateUrlString(url); + return _webViewPlatformController.loadUrl(url, headers); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If [WebView.initialUrl] was never specified, returns `null`. + /// Note that this operation is asynchronous, and it is possible that the + /// current URL changes again by the time this function returns (in other + /// words, by the time this future completes, the WebView may be displaying a + /// different URL). + Future currentUrl() { + return _webViewPlatformController.currentUrl(); + } + + /// Checks whether there's a back history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has + /// changed by the time the future completed. + Future canGoBack() { + return _webViewPlatformController.canGoBack(); + } + + /// Checks whether there's a forward history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has + /// changed by the time the future completed. + Future canGoForward() { + return _webViewPlatformController.canGoForward(); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + return _webViewPlatformController.goBack(); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + return _webViewPlatformController.goForward(); + } + + /// Reloads the current URL. + Future reload() { + return _webViewPlatformController.reload(); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + /// + /// Note: Calling this method also triggers a reload. + Future clearCache() async { + await _webViewPlatformController.clearCache(); + return reload(); + } + + /// Update the widget managed by the [WebViewController]. + Future updateWidget(WebView widget) async { + _widget = widget; + await _updateSettings(_webSettingsFromWidget(widget)); + await _updateJavascriptChannels( + _javascriptChannelRegistry.channels.values.toSet()); + } + + Future _updateSettings(WebSettings newSettings) { + final WebSettings update = + _clearUnchangedWebSettings(_settings, newSettings); + _settings = newSettings; + return _webViewPlatformController.updateSettings(update); + } + + Future _updateJavascriptChannels( + Set? newChannels) async { + final Set currentChannels = + _javascriptChannelRegistry.channels.keys.toSet(); + final Set newChannelNames = _extractChannelNames(newChannels); + final Set channelsToAdd = + newChannelNames.difference(currentChannels); + final Set channelsToRemove = + currentChannels.difference(newChannelNames); + if (channelsToRemove.isNotEmpty) { + await _webViewPlatformController + .removeJavascriptChannels(channelsToRemove); + } + if (channelsToAdd.isNotEmpty) { + await _webViewPlatformController.addJavascriptChannels(channelsToAdd); + } + _javascriptChannelRegistry.updateJavascriptChannelsFromSet(newChannels); + } + + @visibleForTesting + // ignore: public_member_api_docs + Future evaluateJavascript(String javascriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); + } + return _webViewPlatformController.evaluateJavascript(javascriptString); + } + + /// Runs the given JavaScript in the context of the current page. + /// If you are looking for the result, use [runJavascriptReturningResult] instead. + /// The Future completes with an error if a JavaScript error occurred. + /// + /// When running JavaScript in a [WebView], it is best practice to wait for + // the [WebView.onPageFinished] callback. This guarantees all the JavaScript + // embedded in the main frame HTML has been loaded. + Future runJavascript(String javaScriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'Javascript mode must be enabled/unrestricted when calling runJavascript.')); + } + return _webViewPlatformController.runJavascript(javaScriptString); + } + + /// Runs the given JavaScript in the context of the current page, and returns the result. + /// + /// Returns the evaluation result as a JSON formatted string. + /// The Future completes with an error if a JavaScript error occurred. + /// + /// When evaluating JavaScript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the JavaScript + /// embedded in the main frame HTML has been loaded. + Future runJavascriptReturningResult(String javaScriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'Javascript mode must be enabled/unrestricted when calling runJavascriptReturningResult.')); + } + return _webViewPlatformController + .runJavascriptReturningResult(javaScriptString); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + return _webViewPlatformController.getTitle(); + } + + /// Sets the WebView's content scroll position. + /// + /// The parameters `x` and `y` specify the scroll position in WebView pixels. + Future scrollTo(int x, int y) { + return _webViewPlatformController.scrollTo(x, y); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by horizontally and vertically respectively. + Future scrollBy(int x, int y) { + return _webViewPlatformController.scrollBy(x, y); + } + + /// Return the horizontal scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + return _webViewPlatformController.getScrollX(); + } + + /// Return the vertical scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + return _webViewPlatformController.getScrollY(); + } + + // This method assumes that no fields in `currentValue` are null. + WebSettings _clearUnchangedWebSettings( + WebSettings currentValue, WebSettings newValue) { + assert(currentValue.javascriptMode != null); + assert(currentValue.hasNavigationDelegate != null); + assert(currentValue.hasProgressTracking != null); + assert(currentValue.debuggingEnabled != null); + assert(currentValue.userAgent != null); + assert(newValue.javascriptMode != null); + assert(newValue.hasNavigationDelegate != null); + assert(newValue.debuggingEnabled != null); + assert(newValue.userAgent != null); + assert(newValue.zoomEnabled != null); + + JavascriptMode? javascriptMode; + bool? hasNavigationDelegate; + bool? hasProgressTracking; + bool? debuggingEnabled; + WebSetting userAgent = WebSetting.absent(); + bool? zoomEnabled; + if (currentValue.javascriptMode != newValue.javascriptMode) { + javascriptMode = newValue.javascriptMode; + } + if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { + hasNavigationDelegate = newValue.hasNavigationDelegate; + } + if (currentValue.hasProgressTracking != newValue.hasProgressTracking) { + hasProgressTracking = newValue.hasProgressTracking; + } + if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { + debuggingEnabled = newValue.debuggingEnabled; + } + if (currentValue.userAgent != newValue.userAgent) { + userAgent = newValue.userAgent; + } + if (currentValue.zoomEnabled != newValue.zoomEnabled) { + zoomEnabled = newValue.zoomEnabled; + } + + return WebSettings( + javascriptMode: javascriptMode, + hasNavigationDelegate: hasNavigationDelegate, + hasProgressTracking: hasProgressTracking, + debuggingEnabled: debuggingEnabled, + userAgent: userAgent, + zoomEnabled: zoomEnabled, + ); + } + + Set _extractChannelNames(Set? channels) { + final Set channelNames = channels == null + ? {} + : channels.map((JavascriptChannel channel) => channel.name).toSet(); + return channelNames; + } + + // Throws an ArgumentError if `url` is not a valid URL string. + void _validateUrlString(String url) { + try { + final Uri uri = Uri.parse(url); + if (uri.scheme.isEmpty) { + throw ArgumentError('Missing scheme in URL string: "$url"'); + } + } on FormatException catch (e) { + throw ArgumentError(e); + } + } +} + +WebSettings _webSettingsFromWidget(WebView widget) { + return WebSettings( + javascriptMode: widget.javascriptMode, + hasNavigationDelegate: widget.navigationDelegate != null, + hasProgressTracking: widget.onProgress != null, + debuggingEnabled: widget.debuggingEnabled, + gestureNavigationEnabled: widget.gestureNavigationEnabled, + allowsInlineMediaPlayback: widget.allowsInlineMediaPlayback, + userAgent: WebSetting.of(widget.userAgent), + zoomEnabled: widget.zoomEnabled, + ); +} diff --git a/packages/webview_flutter/webview_flutter_android/example/pubspec.yaml b/packages/webview_flutter/webview_flutter_android/example/pubspec.yaml new file mode 100644 index 000000000000..1e065a6a5b0b --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/pubspec.yaml @@ -0,0 +1,33 @@ +name: webview_flutter_android_example +description: Demonstrates how to use the webview_flutter_android plugin. +publish_to: none + +environment: + sdk: ">=2.14.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + webview_flutter_android: + # When depending on this package from a real application you should use: + # webview_flutter: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + espresso: ^0.1.0+2 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter + pedantic: ^1.10.0 + +flutter: + uses-material-design: true + assets: + - assets/sample_audio.ogg + - assets/sample_video.mp4 diff --git a/packages/webview_flutter/webview_flutter_android/example/test_driver/integration_test.dart b/packages/webview_flutter/webview_flutter_android/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/webview_flutter/webview_flutter_android/generatePigeons.sh b/packages/webview_flutter/webview_flutter_android/generatePigeons.sh new file mode 100755 index 000000000000..30a6918fc922 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/generatePigeons.sh @@ -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. + +flutter pub run pigeon \ +--input pigeons/android_webview.dart \ +--dart_out lib/src/android_webview.pigeon.dart \ +--dart_test_out test/android_webview.pigeon.dart \ +--java_out android/src/main/java/io/flutter/plugins/webviewflutter/GeneratedAndroidWebView.Java \ +--java_package io.flutter.plugins.webviewflutter diff --git a/packages/webview_flutter/webview_flutter_android/lib/src/android_webview.dart b/packages/webview_flutter/webview_flutter_android/lib/src/android_webview.dart new file mode 100644 index 000000000000..1ddd6f3d9f0f --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/src/android_webview.dart @@ -0,0 +1,648 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show AndroidViewSurface; + +import 'android_webview_api_impls.dart'; + +// TODO(bparrishMines): This can be removed once pigeon supports null values: https://github.com/flutter/flutter/issues/59118 +// Workaround to represent null Strings since pigeon doesn't support null +// values. +const String _nullStringIdentifier = ''; + +/// An Android View that displays web pages. +/// +/// **Basic usage** +/// In most cases, we recommend using a standard web browser, like Chrome, to +/// deliver content to the user. To learn more about web browsers, read the +/// guide on invoking a browser with +/// [url_launcher](https://pub.dev/packages/url_launcher). +/// +/// WebView objects allow you to display web content as part of your widget +/// layout, but lack some of the features of fully-developed browsers. A WebView +/// is useful when you need increased control over the UI and advanced +/// configuration options that will allow you to embed web pages in a +/// specially-designed environment for your app. +/// +/// To learn more about WebView and alternatives for serving web content, read +/// the documentation on +/// [Web-based content](https://developer.android.com/guide/webapps). +class WebView { + /// Constructs a new WebView. + WebView({this.useHybridComposition = false}) { + api.createFromInstance(this); + } + + /// Pigeon Host Api implementation for [WebView]. + @visibleForTesting + static WebViewHostApiImpl api = WebViewHostApiImpl(); + + WebViewClient? _currentWebViewClient; + DownloadListener? _currentDownloadListener; + WebChromeClient? _currentWebChromeClient; + Set _javaScriptChannels = {}; + + /// Whether the [WebView] will be rendered with an [AndroidViewSurface]. + /// + /// This implementation uses hybrid composition to render the WebView Widget. + /// This comes at the cost of some performance on Android versions below 10. + /// See + /// https://flutter.dev/docs/development/platform-integration/platform-views#performance + /// for more information. + /// + /// Defaults to false. + final bool useHybridComposition; + + /// The [WebSettings] object used to control the settings for this WebView. + late final WebSettings settings = WebSettings(this); + + /// Enables debugging of web contents (HTML / CSS / JavaScript) loaded into any WebViews of this application. + /// + /// This flag can be enabled in order to facilitate debugging of web layouts + /// and JavaScript code running inside WebViews. Please refer to [WebView] + /// documentation for the debugging guide. The default is false. + static Future setWebContentsDebuggingEnabled(bool enabled) { + return api.setWebContentsDebuggingEnabled(enabled); + } + + /// Loads the given URL with additional HTTP headers, specified as a map from name to value. + /// + /// Note that if this map contains any of the headers that are set by default + /// by this WebView, such as those controlling caching, accept types or the + /// User-Agent, their values may be overridden by this WebView's defaults. + /// + /// Also see compatibility note on [evaluateJavascript]. + Future loadUrl(String url, Map headers) { + return api.loadUrlFromInstance(this, url, headers); + } + + /// Gets the URL for the current page. + /// + /// This is not always the same as the URL passed to + /// [WebViewClient.onPageStarted] because although the load for that URL has + /// begun, the current page may not have changed. + /// + /// Returns null if no page has been loaded. + Future getUrl() async { + final String result = await api.getUrlFromInstance(this); + if (result == _nullStringIdentifier) return null; + return result; + } + + /// Whether this WebView has a back history item. + Future canGoBack() { + return api.canGoBackFromInstance(this); + } + + /// Whether this WebView has a forward history item. + Future canGoForward() { + return api.canGoForwardFromInstance(this); + } + + /// Goes back in the history of this WebView. + Future goBack() { + return api.goBackFromInstance(this); + } + + /// Goes forward in the history of this WebView. + Future goForward() { + return api.goForwardFromInstance(this); + } + + /// Reloads the current URL. + Future reload() { + return api.reloadFromInstance(this); + } + + /// Clears the resource cache. + /// + /// Note that the cache is per-application, so this will clear the cache for + /// all WebViews used. + Future clearCache(bool includeDiskFiles) { + return api.clearCacheFromInstance(this, includeDiskFiles); + } + + // TODO(bparrishMines): Update documentation once addJavascriptInterface is added. + /// Asynchronously evaluates JavaScript in the context of the currently displayed page. + /// + /// If non-null, the returned value will be any result returned from that + /// execution. + /// + /// Compatibility note. Applications targeting Android versions N or later, + /// JavaScript state from an empty WebView is no longer persisted across + /// navigations like [loadUrl]. For example, global variables and functions + /// defined before calling [loadUrl]) will not exist in the loaded page. + Future evaluateJavascript(String javascriptString) async { + final String result = await api.evaluateJavascriptFromInstance( + this, + javascriptString, + ); + if (result == _nullStringIdentifier) return null; + return result; + } + + // TODO(bparrishMines): Update documentation when WebViewClient.onReceivedTitle is added. + /// Gets the title for the current page. + /// + /// Returns null if no page has been loaded. + Future getTitle() async { + final String result = await api.getTitleFromInstance(this); + if (result == _nullStringIdentifier) return null; + return result; + } + + // TODO(bparrishMines): Update documentation when onScrollChanged is added. + /// Set the scrolled position of your view. + Future scrollTo(int x, int y) { + return api.scrollToFromInstance(this, x, y); + } + + // TODO(bparrishMines): Update documentation when onScrollChanged is added. + /// Move the scrolled position of your view. + Future scrollBy(int x, int y) { + return api.scrollByFromInstance(this, x, y); + } + + /// Return the scrolled left position of this view. + /// + /// This is the left edge of the displayed part of your view. You do not + /// need to draw any pixels farther left, since those are outside of the frame + /// of your view on screen. + Future getScrollX() { + return api.getScrollXFromInstance(this); + } + + /// Return the scrolled top position of this view. + /// + /// This is the top edge of the displayed part of your view. You do not need + /// to draw any pixels above it, since those are outside of the frame of your + /// view on screen. + Future getScrollY() { + return api.getScrollYFromInstance(this); + } + + /// Sets the [WebViewClient] that will receive various notifications and requests. + /// + /// This will replace the current handler. + Future setWebViewClient(WebViewClient webViewClient) { + final WebViewClient? currentWebViewClient = _currentWebViewClient; + + if (webViewClient == currentWebViewClient) { + return Future.value(); + } + + if (currentWebViewClient != null) { + WebViewClient.api.disposeFromInstance(currentWebViewClient); + } + + WebViewClient.api.createFromInstance(webViewClient); + _currentWebViewClient = webViewClient; + return api.setWebViewClientFromInstance(this, webViewClient); + } + + /// Injects the supplied [JavascriptChannel] into this WebView. + /// + /// The object is injected into all frames of the web page, including all the + /// iframes, using the supplied name. This allows the object's methods to + /// be accessed from JavaScript. + /// + /// Note that injected objects will not appear in JavaScript until the page is + /// next (re)loaded. JavaScript should be enabled before injecting the object. + /// For example: + /// + /// ```dart + /// webview.settings.setJavaScriptEnabled(true); + /// webView.addJavascriptChannel(JavScriptChannel("injectedObject")); + /// webView.loadUrl("about:blank", {}); + /// webView.loadUrl("javascript:injectedObject.postMessage("Hello, World!")", {}); + /// ``` + /// + /// **Important** + /// * Because the object is exposed to all the frames, any frame could obtain + /// the object name and call methods on it. There is no way to tell the + /// calling frame's origin from the app side, so the app must not assume that + /// the caller is trustworthy unless the app can guarantee that no third party + /// content is ever loaded into the WebView even inside an iframe. + Future addJavaScriptChannel(JavaScriptChannel javaScriptChannel) { + JavaScriptChannel.api.createFromInstance(javaScriptChannel); + _javaScriptChannels.add(javaScriptChannel); + return api.addJavaScriptChannelFromInstance(this, javaScriptChannel); + } + + /// Removes a previously injected [JavaScriptChannel] from this WebView. + /// + /// Note that the removal will not be reflected in JavaScript until the page + /// is next (re)loaded. See [addJavaScriptChannel]. + Future removeJavaScriptChannel(JavaScriptChannel javaScriptChannel) { + _javaScriptChannels.remove(javaScriptChannel); + api.removeJavaScriptChannelFromInstance(this, javaScriptChannel); + return JavaScriptChannel.api.disposeFromInstance(javaScriptChannel); + } + + /// Registers the interface to be used when content can not be handled by the rendering engine, and should be downloaded instead. + /// + /// This will replace the current handler. + Future setDownloadListener(DownloadListener listener) { + final DownloadListener? currentDownloadListener = _currentDownloadListener; + + if (listener == currentDownloadListener) { + return Future.value(); + } + + if (currentDownloadListener != null) { + DownloadListener.api.disposeFromInstance(currentDownloadListener); + } + + DownloadListener.api.createFromInstance(listener); + _currentDownloadListener = listener; + return api.setDownloadListenerFromInstance(this, listener); + } + + /// 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. + Future setWebChromeClient(WebChromeClient client) { + final WebChromeClient? currentWebChromeClient = _currentWebChromeClient; + + if (client == currentWebChromeClient) { + return Future.value(); + } + + if (currentWebChromeClient != null) { + WebChromeClient.api.disposeFromInstance(currentWebChromeClient); + } + + final WebViewClient? currentWebViewClient = _currentWebViewClient; + assert( + currentWebViewClient != null, + "Can't set a WebChromeClient without setting a WebViewClient first.", + ); + + WebChromeClient.api.createFromInstance(client, currentWebViewClient!); + _currentWebChromeClient = client; + return api.setWebChromeClientFromInstance(this, client); + } +} + +/// Manages settings state for a [WebView]. +/// +/// When a WebView is first created, it obtains a set of default settings. These +/// default settings will be returned from any getter call. A WebSettings object +/// obtained from [WebView.settings] is tied to the life of the WebView. If a +/// WebView has been destroyed, any method call on [WebSettings] will throw an +/// Exception. +class WebSettings { + /// Constructs a [WebSettings]. + /// + /// This constructor is only used for testing. An instance should be obtained + /// with [WebView.settings]. + @visibleForTesting + WebSettings(WebView webView) { + api.createFromInstance(this, webView); + } + + /// Pigeon Host Api implementation for [WebSettings]. + @visibleForTesting + static WebSettingsHostApiImpl api = WebSettingsHostApiImpl(); + + /// Sets whether the DOM storage API is enabled. + /// + /// The default value is false. + Future setDomStorageEnabled(bool flag) { + return api.setDomStorageEnabledFromInstance(this, flag); + } + + /// Tells JavaScript to open windows automatically. + /// + /// This applies to the JavaScript function `window.open()`. The default is + /// false. + Future setJavaScriptCanOpenWindowsAutomatically(bool flag) { + return api.setJavaScriptCanOpenWindowsAutomaticallyFromInstance( + this, + flag, + ); + } + + // TODO(bparrishMines): Update documentation when WebChromeClient.onCreateWindow is added. + /// Sets whether the WebView should supports multiple windows. + /// + /// The default is false. + Future setSupportMultipleWindows(bool support) { + return api.setSupportZoomFromInstance(this, support); + } + + /// Tells the WebView to enable JavaScript execution. + /// + /// The default is false. + Future setJavaScriptEnabled(bool flag) { + return api.setJavaScriptEnabledFromInstance(this, flag); + } + + /// Sets the WebView's user-agent string. + /// + /// If the string is empty, the system default value will be used. Note that + /// starting from KITKAT Android version, changing the user-agent while + /// loading a web page causes WebView to initiate loading once again. + Future setUserAgentString(String userAgentString) { + return api.setUserAgentStringFromInstance(this, userAgentString); + } + + /// Sets whether the WebView requires a user gesture to play media. + /// + /// The default is true. + Future setMediaPlaybackRequiresUserGesture(bool require) { + return api.setMediaPlaybackRequiresUserGestureFromInstance(this, require); + } + + // TODO(bparrishMines): Update documentation when WebView.zoomIn and WebView.zoomOut are added. + /// Sets whether the WebView should support zooming using its on-screen zoom controls and gestures. + /// + /// The particular zoom mechanisms that should be used can be set with + /// [setBuiltInZoomControls]. + /// + /// The default is true. + Future setSupportZoom(bool support) { + return api.setSupportZoomFromInstance(this, support); + } + + /// Sets whether the WebView loads pages in overview mode, that is, zooms out the content to fit on screen by width. + /// + /// This setting is taken into account when the content width is greater than + /// the width of the WebView control, for example, when [setUseWideViewPort] + /// is enabled. + /// + /// The default is false. + Future setLoadWithOverviewMode(bool overview) { + return api.setLoadWithOverviewModeFromInstance(this, overview); + } + + /// Sets whether the WebView should enable support for the "viewport" HTML meta tag or should use a wide viewport. + /// + /// When the value of the setting is false, the layout width is always set to + /// the width of the WebView control in device-independent (CSS) pixels. When + /// the value is true and the page contains the viewport meta tag, the value + /// of the width specified in the tag is used. If the page does not contain + /// the tag or does not provide a width, then a wide viewport will be used. + Future setUseWideViewPort(bool use) { + return api.setUseWideViewPortFromInstance(this, use); + } + + // TODO(bparrishMines): Update documentation when ZoomButtonsController is added. + /// Sets whether the WebView should display on-screen zoom controls when using the built-in zoom mechanisms. + /// + /// See [setBuiltInZoomControls]. The default is true. However, on-screen zoom + /// controls are deprecated in Android so it's recommended to set this to + /// false. + Future setDisplayZoomControls(bool enabled) { + return api.setDisplayZoomControlsFromInstance(this, enabled); + } + + // TODO(bparrishMines): Update documentation when ZoomButtonsController is added. + /// Sets whether the WebView should use its built-in zoom mechanisms. + /// + /// The built-in zoom mechanisms comprise on-screen zoom controls, which are + /// displayed over the WebView's content, and the use of a pinch gesture to + /// control zooming. Whether or not these on-screen controls are displayed can + /// be set with [setDisplayZoomControls]. The default is false. + /// + /// The built-in mechanisms are the only currently supported zoom mechanisms, + /// so it is recommended that this setting is always enabled. However, + /// on-screen zoom controls are deprecated in Android so it's recommended to + /// disable [setDisplayZoomControls]. + Future setBuiltInZoomControls(bool enabled) { + return api.setBuiltInZoomControlsFromInstance(this, enabled); + } +} + +/// Exposes a channel to receive calls from javaScript. +/// +/// See [WebView.addJavaScriptChannel]. +abstract class JavaScriptChannel { + /// Constructs a [JavaScriptChannel]. + JavaScriptChannel(this.channelName); + + /// Pigeon Host Api implementation for [JavaScriptChannel]. + @visibleForTesting + static JavaScriptChannelHostApiImpl api = JavaScriptChannelHostApiImpl(); + + /// Used to identify this object to receive messages from javaScript. + final String channelName; + + /// Callback method when javaScript calls `postMessage` on the object instance passed. + void postMessage(String message) {} +} + +/// Receive various notifications and requests for [WebView]. +abstract class WebViewClient { + /// Constructs a [WebViewClient]. + WebViewClient({this.shouldOverrideUrlLoading = true}); + + /// User authentication failed on server. + static const int errorAuthentication = 0xfffffffc; + + /// Malformed URL. + static const int errorBadUrl = 0xfffffff4; + + /// Failed to connect to the server. + static const int errorConnect = 0xfffffffa; + + /// Failed to perform SSL handshake. + static const int errorFailedSslHandshake = 0xfffffff5; + + /// Generic file error. + static const int errorFile = 0xfffffff3; + + /// File not found. + static const int errorFileNotFound = 0xfffffff2; + + /// Server or proxy hostname lookup failed. + static const int errorHostLookup = 0xfffffffe; + + /// Failed to read or write to the server. + static const int errorIO = 0xfffffff9; + + /// User authentication failed on proxy. + static const int errorProxyAuthentication = 0xfffffffb; + + /// Too many redirects. + static const int errorRedirectLoop = 0xfffffff7; + + /// Connection timed out. + static const int errorTimeout = 0xfffffff8; + + /// Too many requests during this load. + static const int errorTooManyRequests = 0xfffffff1; + + /// Generic error. + static const int errorUnknown = 0xffffffff; + + /// Resource load was canceled by Safe Browsing. + static const int errorUnsafeResource = 0xfffffff0; + + /// Unsupported authentication scheme (not basic or digest). + static const int errorUnsupportedAuthScheme = 0xfffffffd; + + /// Unsupported URI scheme. + static const int errorUnsupportedScheme = 0xfffffff6; + + /// Pigeon Host Api implementation for [WebViewClient]. + @visibleForTesting + static WebViewClientHostApiImpl api = WebViewClientHostApiImpl(); + + /// Whether loading a url should be overridden. + /// + /// In Java, `shouldOverrideUrlLoading()` and `shouldOverrideRequestLoading()` + /// callbacks must synchronously return a boolean. This sets the default + /// return value. + /// + /// Setting [shouldOverrideUrlLoading] to true causes the current [WebView] to + /// abort loading the URL, while returning false causes the [WebView] to + /// continue loading the URL as usual. [requestLoading] or [urlLoading] will + /// still be called either way. + /// + /// Defaults to true. + final bool shouldOverrideUrlLoading; + + /// Notify the host application that a page has started loading. + /// + /// This method is called once for each main frame load so a page with iframes + /// or framesets will call onPageStarted one time for the main frame. This + /// also means that [onPageStarted] will not be called when the contents of an + /// embedded frame changes, i.e. clicking a link whose target is an iframe, it + /// will also not be called for fragment navigations (navigations to + /// #fragment_id). + void onPageStarted(WebView webView, String url) {} + + // TODO(bparrishMines): Update documentation when WebView.postVisualStateCallback is added. + /// Notify the host application that a page has finished loading. + /// + /// This method is called only for main frame. Receiving an [onPageFinished] + /// callback does not guarantee that the next frame drawn by WebView will + /// reflect the state of the DOM at this point. + void onPageFinished(WebView webView, String url) {} + + /// Report web resource loading error to the host application. + /// + /// These errors usually indicate inability to connect to the server. Note + /// that unlike the deprecated version of the callback, the new version will + /// be called for any resource (iframe, image, etc.), not just for the main + /// page. Thus, it is recommended to perform minimum required work in this + /// callback. + void onReceivedRequestError( + WebView webView, + WebResourceRequest request, + WebResourceError error, + ) {} + + /// Report an error to the host application. + /// + /// These errors are unrecoverable (i.e. the main resource is unavailable). + /// The errorCode parameter corresponds to one of the error* constants. + @Deprecated('Only called on Android version < 23.') + void onReceivedError( + WebView webView, + int errorCode, + String description, + String failingUrl, + ) {} + + // TODO(bparrishMines): Update documentation once synchronous url handling is supported. + /// When a URL is about to be loaded in the current [WebView]. + /// + /// If a [WebViewClient] is not provided, by default [WebView] will ask + /// Activity Manager to choose the proper handler for the URL. If a + /// [WebViewClient] is provided, setting [shouldOverrideUrlLoading] to true + /// causes the current [WebView] to abort loading the URL, while returning + /// false causes the [WebView] to continue loading the URL as usual. + void requestLoading(WebView webView, WebResourceRequest request) {} + + // TODO(bparrishMines): Update documentation once synchronous url handling is supported. + /// When a URL is about to be loaded in the current [WebView]. + /// + /// If a [WebViewClient] is not provided, by default [WebView] will ask + /// Activity Manager to choose the proper handler for the URL. If a + /// [WebViewClient] is provided, setting [shouldOverrideUrlLoading] to true + /// causes the current [WebView] to abort loading the URL, while returning + /// false causes the [WebView] to continue loading the URL as usual. + void urlLoading(WebView webView, String url) {} +} + +/// The interface to be used when content can not be handled by the rendering engine for [WebView], and should be downloaded instead. +abstract class DownloadListener { + /// Pigeon Host Api implementation for [DownloadListener]. + @visibleForTesting + static DownloadListenerHostApiImpl api = DownloadListenerHostApiImpl(); + + /// Notify the host application that a file should be downloaded. + void onDownloadStart( + String url, + String userAgent, + String contentDisposition, + String mimetype, + int contentLength, + ); +} + +/// Handles JavaScript dialogs, favicons, titles, and the progress for [WebView]. +abstract class WebChromeClient { + /// Pigeon Host Api implementation for [WebChromeClient]. + @visibleForTesting + static WebChromeClientHostApiImpl api = WebChromeClientHostApiImpl(); + + /// Notify the host application that a file should be downloaded. + void onProgressChanged(WebView webView, int progress); +} + +/// Encompasses parameters to the [WebViewClient.requestLoading] method. +class WebResourceRequest { + /// Constructs a [WebResourceRequest]. + WebResourceRequest({ + required this.url, + required this.isForMainFrame, + required this.isRedirect, + required this.hasGesture, + required this.method, + required this.requestHeaders, + }); + + /// Gets the URL for which the resource request was made. + final String url; + + /// Gets whether the request was made in order to fetch the main frame's document. + final isForMainFrame; + + /// Gets whether the request was a result of a server-side redirect. + /// + /// Only supported on Android version >= 24. + final bool? isRedirect; + + /// Gets whether a gesture (such as a click) was associated with the request. + final bool hasGesture; + + /// Gets the method associated with the request, for example "GET". + final String method; + + /// Gets the headers associated with the request. + final Map requestHeaders; +} + +/// Encapsulates information about errors occurred during loading of web resources. +/// +/// See [WebViewClient.onReceivedRequestError]. +class WebResourceError { + /// Constructs a [WebResourceError]. + WebResourceError({ + required this.errorCode, + required this.description, + }); + + /// The integer code of the error (e.g. [WebViewClient.errorAuthentication]. + final int errorCode; + + /// Describes the error. + final String description; +} diff --git a/packages/webview_flutter/webview_flutter_android/lib/src/android_webview.pigeon.dart b/packages/webview_flutter/webview_flutter_android/lib/src/android_webview.pigeon.dart new file mode 100644 index 000000000000..81ec7c7bf80e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/src/android_webview.pigeon.dart @@ -0,0 +1,1601 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Autogenerated from Pigeon (v1.0.7), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name +// @dart = 2.12 +import 'dart:async'; +import 'dart:typed_data' show Uint8List, Int32List, Int64List, Float64List; + +import 'package:flutter/foundation.dart' show WriteBuffer, ReadBuffer; +import 'package:flutter/services.dart'; + +class WebResourceRequestData { + String? url; + bool? isForMainFrame; + bool? isRedirect; + bool? hasGesture; + String? method; + Map? requestHeaders; + + Object encode() { + final Map pigeonMap = {}; + pigeonMap['url'] = url; + pigeonMap['isForMainFrame'] = isForMainFrame; + pigeonMap['isRedirect'] = isRedirect; + pigeonMap['hasGesture'] = hasGesture; + pigeonMap['method'] = method; + pigeonMap['requestHeaders'] = requestHeaders; + return pigeonMap; + } + + static WebResourceRequestData decode(Object message) { + final Map pigeonMap = message as Map; + return WebResourceRequestData() + ..url = pigeonMap['url'] as String? + ..isForMainFrame = pigeonMap['isForMainFrame'] as bool? + ..isRedirect = pigeonMap['isRedirect'] as bool? + ..hasGesture = pigeonMap['hasGesture'] as bool? + ..method = pigeonMap['method'] as String? + ..requestHeaders = (pigeonMap['requestHeaders'] as Map?) + ?.cast(); + } +} + +class WebResourceErrorData { + int? errorCode; + String? description; + + Object encode() { + final Map pigeonMap = {}; + pigeonMap['errorCode'] = errorCode; + pigeonMap['description'] = description; + return pigeonMap; + } + + static WebResourceErrorData decode(Object message) { + final Map pigeonMap = message as Map; + return WebResourceErrorData() + ..errorCode = pigeonMap['errorCode'] as int? + ..description = pigeonMap['description'] as String?; + } +} + +class _WebViewHostApiCodec extends StandardMessageCodec { + const _WebViewHostApiCodec(); +} + +class WebViewHostApi { + /// Constructor for [WebViewHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + WebViewHostApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _WebViewHostApiCodec(); + + Future create(int arg_instanceId, bool arg_useHybridComposition) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.create', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_useHybridComposition]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future dispose(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.dispose', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future loadUrl(int arg_instanceId, String arg_url, + Map arg_headers) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.loadUrl', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_url, arg_headers]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future getUrl(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getUrl', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return (replyMap['result'] as String?)!; + } + } + + Future canGoBack(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.canGoBack', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return (replyMap['result'] as bool?)!; + } + } + + Future canGoForward(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.canGoForward', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return (replyMap['result'] as bool?)!; + } + } + + Future goBack(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.goBack', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future goForward(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.goForward', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future reload(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.reload', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future clearCache(int arg_instanceId, bool arg_includeDiskFiles) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.clearCache', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_includeDiskFiles]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future evaluateJavascript( + int arg_instanceId, String arg_javascriptString) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.evaluateJavascript', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_javascriptString]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return (replyMap['result'] as String?)!; + } + } + + Future getTitle(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getTitle', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return (replyMap['result'] as String?)!; + } + } + + Future scrollTo(int arg_instanceId, int arg_x, int arg_y) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.scrollTo', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_x, arg_y]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future scrollBy(int arg_instanceId, int arg_x, int arg_y) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.scrollBy', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_x, arg_y]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future getScrollX(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getScrollX', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return (replyMap['result'] as int?)!; + } + } + + Future getScrollY(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getScrollY', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return (replyMap['result'] as int?)!; + } + } + + Future setWebContentsDebuggingEnabled(bool arg_enabled) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setWebContentsDebuggingEnabled', + codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_enabled]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setWebViewClient( + int arg_instanceId, int arg_webViewClientInstanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setWebViewClient', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_webViewClientInstanceId]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future addJavaScriptChannel( + int arg_instanceId, int arg_javaScriptChannelInstanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.addJavaScriptChannel', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_javaScriptChannelInstanceId]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future removeJavaScriptChannel( + int arg_instanceId, int arg_javaScriptChannelInstanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.removeJavaScriptChannel', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_javaScriptChannelInstanceId]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setDownloadListener( + int arg_instanceId, int arg_listenerInstanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setDownloadListener', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_listenerInstanceId]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setWebChromeClient( + int arg_instanceId, int arg_clientInstanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setWebChromeClient', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_clientInstanceId]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} + +class _WebSettingsHostApiCodec extends StandardMessageCodec { + const _WebSettingsHostApiCodec(); +} + +class WebSettingsHostApi { + /// Constructor for [WebSettingsHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + WebSettingsHostApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _WebSettingsHostApiCodec(); + + Future create(int arg_instanceId, int arg_webViewInstanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.create', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_webViewInstanceId]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future dispose(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.dispose', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setDomStorageEnabled(int arg_instanceId, bool arg_flag) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setDomStorageEnabled', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_flag]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setJavaScriptCanOpenWindowsAutomatically( + int arg_instanceId, bool arg_flag) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptCanOpenWindowsAutomatically', + codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_flag]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setSupportMultipleWindows( + int arg_instanceId, bool arg_support) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setSupportMultipleWindows', + codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_support]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setJavaScriptEnabled(int arg_instanceId, bool arg_flag) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptEnabled', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_flag]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setUserAgentString( + int arg_instanceId, String arg_userAgentString) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setUserAgentString', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_userAgentString]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setMediaPlaybackRequiresUserGesture( + int arg_instanceId, bool arg_require) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setMediaPlaybackRequiresUserGesture', + codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_require]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setSupportZoom(int arg_instanceId, bool arg_support) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setSupportZoom', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_support]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setLoadWithOverviewMode( + int arg_instanceId, bool arg_overview) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setLoadWithOverviewMode', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_overview]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setUseWideViewPort(int arg_instanceId, bool arg_use) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setUseWideViewPort', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_use]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setDisplayZoomControls( + int arg_instanceId, bool arg_enabled) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setDisplayZoomControls', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_enabled]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future setBuiltInZoomControls( + int arg_instanceId, bool arg_enabled) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setBuiltInZoomControls', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_enabled]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} + +class _JavaScriptChannelHostApiCodec extends StandardMessageCodec { + const _JavaScriptChannelHostApiCodec(); +} + +class JavaScriptChannelHostApi { + /// Constructor for [JavaScriptChannelHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + JavaScriptChannelHostApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _JavaScriptChannelHostApiCodec(); + + Future create(int arg_instanceId, String arg_channelName) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.JavaScriptChannelHostApi.create', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId, arg_channelName]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future dispose(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.JavaScriptChannelHostApi.dispose', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} + +class _JavaScriptChannelFlutterApiCodec extends StandardMessageCodec { + const _JavaScriptChannelFlutterApiCodec(); +} + +abstract class JavaScriptChannelFlutterApi { + static const MessageCodec codec = + _JavaScriptChannelFlutterApiCodec(); + + void postMessage(int instanceId, String message); + static void setup(JavaScriptChannelFlutterApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.JavaScriptChannelFlutterApi.postMessage', codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelFlutterApi.postMessage was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelFlutterApi.postMessage was null, expected non-null int.'); + final String? arg_message = args[1] as String?; + assert(arg_message != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelFlutterApi.postMessage was null, expected non-null String.'); + api.postMessage(arg_instanceId!, arg_message!); + return; + }); + } + } + } +} + +class _WebViewClientHostApiCodec extends StandardMessageCodec { + const _WebViewClientHostApiCodec(); +} + +class WebViewClientHostApi { + /// Constructor for [WebViewClientHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + WebViewClientHostApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _WebViewClientHostApiCodec(); + + Future create( + int arg_instanceId, bool arg_shouldOverrideUrlLoading) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientHostApi.create', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_shouldOverrideUrlLoading]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future dispose(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientHostApi.dispose', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} + +class _WebViewClientFlutterApiCodec extends StandardMessageCodec { + const _WebViewClientFlutterApiCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is WebResourceErrorData) { + buffer.putUint8(128); + writeValue(buffer, value.encode()); + } else if (value is WebResourceRequestData) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is WebResourceRequestData) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 128: + return WebResourceErrorData.decode(readValue(buffer)!); + + case 129: + return WebResourceRequestData.decode(readValue(buffer)!); + + case 130: + return WebResourceRequestData.decode(readValue(buffer)!); + + default: + return super.readValueOfType(type, buffer); + } + } +} + +abstract class WebViewClientFlutterApi { + static const MessageCodec codec = _WebViewClientFlutterApiCodec(); + + void onPageStarted(int instanceId, int webViewInstanceId, String url); + void onPageFinished(int instanceId, int webViewInstanceId, String url); + void onReceivedRequestError(int instanceId, int webViewInstanceId, + WebResourceRequestData request, WebResourceErrorData error); + void onReceivedError(int instanceId, int webViewInstanceId, int errorCode, + String description, String failingUrl); + void requestLoading( + int instanceId, int webViewInstanceId, WebResourceRequestData request); + void urlLoading(int instanceId, int webViewInstanceId, String url); + static void setup(WebViewClientFlutterApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientFlutterApi.onPageStarted', codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageStarted was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageStarted was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageStarted was null, expected non-null int.'); + final String? arg_url = args[2] as String?; + assert(arg_url != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageStarted was null, expected non-null String.'); + api.onPageStarted(arg_instanceId!, arg_webViewInstanceId!, arg_url!); + return; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientFlutterApi.onPageFinished', codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageFinished was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageFinished was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageFinished was null, expected non-null int.'); + final String? arg_url = args[2] as String?; + assert(arg_url != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onPageFinished was null, expected non-null String.'); + api.onPageFinished(arg_instanceId!, arg_webViewInstanceId!, arg_url!); + return; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedRequestError', + codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedRequestError was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedRequestError was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedRequestError was null, expected non-null int.'); + final WebResourceRequestData? arg_request = + args[2] as WebResourceRequestData?; + assert(arg_request != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedRequestError was null, expected non-null WebResourceRequestData.'); + final WebResourceErrorData? arg_error = + args[3] as WebResourceErrorData?; + assert(arg_error != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedRequestError was null, expected non-null WebResourceErrorData.'); + api.onReceivedRequestError(arg_instanceId!, arg_webViewInstanceId!, + arg_request!, arg_error!); + return; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError', codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError was null, expected non-null int.'); + final int? arg_errorCode = args[2] as int?; + assert(arg_errorCode != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError was null, expected non-null int.'); + final String? arg_description = args[3] as String?; + assert(arg_description != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError was null, expected non-null String.'); + final String? arg_failingUrl = args[4] as String?; + assert(arg_failingUrl != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.onReceivedError was null, expected non-null String.'); + api.onReceivedError(arg_instanceId!, arg_webViewInstanceId!, + arg_errorCode!, arg_description!, arg_failingUrl!); + return; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientFlutterApi.requestLoading', codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.requestLoading was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.requestLoading was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.requestLoading was null, expected non-null int.'); + final WebResourceRequestData? arg_request = + args[2] as WebResourceRequestData?; + assert(arg_request != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.requestLoading was null, expected non-null WebResourceRequestData.'); + api.requestLoading( + arg_instanceId!, arg_webViewInstanceId!, arg_request!); + return; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientFlutterApi.urlLoading', codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.urlLoading was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.urlLoading was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.urlLoading was null, expected non-null int.'); + final String? arg_url = args[2] as String?; + assert(arg_url != null, + 'Argument for dev.flutter.pigeon.WebViewClientFlutterApi.urlLoading was null, expected non-null String.'); + api.urlLoading(arg_instanceId!, arg_webViewInstanceId!, arg_url!); + return; + }); + } + } + } +} + +class _DownloadListenerHostApiCodec extends StandardMessageCodec { + const _DownloadListenerHostApiCodec(); +} + +class DownloadListenerHostApi { + /// Constructor for [DownloadListenerHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + DownloadListenerHostApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _DownloadListenerHostApiCodec(); + + Future create(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DownloadListenerHostApi.create', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future dispose(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DownloadListenerHostApi.dispose', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} + +class _DownloadListenerFlutterApiCodec extends StandardMessageCodec { + const _DownloadListenerFlutterApiCodec(); +} + +abstract class DownloadListenerFlutterApi { + static const MessageCodec codec = _DownloadListenerFlutterApiCodec(); + + void onDownloadStart(int instanceId, String url, String userAgent, + String contentDisposition, String mimetype, int contentLength); + static void setup(DownloadListenerFlutterApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart', + codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart was null, expected non-null int.'); + final String? arg_url = args[1] as String?; + assert(arg_url != null, + 'Argument for dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart was null, expected non-null String.'); + final String? arg_userAgent = args[2] as String?; + assert(arg_userAgent != null, + 'Argument for dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart was null, expected non-null String.'); + final String? arg_contentDisposition = args[3] as String?; + assert(arg_contentDisposition != null, + 'Argument for dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart was null, expected non-null String.'); + final String? arg_mimetype = args[4] as String?; + assert(arg_mimetype != null, + 'Argument for dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart was null, expected non-null String.'); + final int? arg_contentLength = args[5] as int?; + assert(arg_contentLength != null, + 'Argument for dev.flutter.pigeon.DownloadListenerFlutterApi.onDownloadStart was null, expected non-null int.'); + api.onDownloadStart(arg_instanceId!, arg_url!, arg_userAgent!, + arg_contentDisposition!, arg_mimetype!, arg_contentLength!); + return; + }); + } + } + } +} + +class _WebChromeClientHostApiCodec extends StandardMessageCodec { + const _WebChromeClientHostApiCodec(); +} + +class WebChromeClientHostApi { + /// Constructor for [WebChromeClientHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + WebChromeClientHostApi({BinaryMessenger? binaryMessenger}) + : _binaryMessenger = binaryMessenger; + + final BinaryMessenger? _binaryMessenger; + + static const MessageCodec codec = _WebChromeClientHostApiCodec(); + + Future create( + int arg_instanceId, int arg_webViewClientInstanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebChromeClientHostApi.create', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = await channel + .send([arg_instanceId, arg_webViewClientInstanceId]) + as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } + + Future dispose(int arg_instanceId) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebChromeClientHostApi.dispose', codec, + binaryMessenger: _binaryMessenger); + final Map? replyMap = + await channel.send([arg_instanceId]) as Map?; + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null, + ); + } else if (replyMap['error'] != null) { + final Map error = + (replyMap['error'] as Map?)!; + throw PlatformException( + code: (error['code'] as String?)!, + message: error['message'] as String?, + details: error['details'], + ); + } else { + return; + } + } +} + +class _WebChromeClientFlutterApiCodec extends StandardMessageCodec { + const _WebChromeClientFlutterApiCodec(); +} + +abstract class WebChromeClientFlutterApi { + static const MessageCodec codec = _WebChromeClientFlutterApiCodec(); + + void onProgressChanged(int instanceId, int webViewInstanceId, int progress); + static void setup(WebChromeClientFlutterApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebChromeClientFlutterApi.onProgressChanged', + codec); + if (api == null) { + channel.setMessageHandler(null); + } else { + channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebChromeClientFlutterApi.onProgressChanged was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebChromeClientFlutterApi.onProgressChanged was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebChromeClientFlutterApi.onProgressChanged was null, expected non-null int.'); + final int? arg_progress = args[2] as int?; + assert(arg_progress != null, + 'Argument for dev.flutter.pigeon.WebChromeClientFlutterApi.onProgressChanged was null, expected non-null int.'); + api.onProgressChanged( + arg_instanceId!, arg_webViewInstanceId!, arg_progress!); + return; + }); + } + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/lib/src/android_webview_api_impls.dart b/packages/webview_flutter/webview_flutter_android/lib/src/android_webview_api_impls.dart new file mode 100644 index 000000000000..f909e49bd802 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/src/android_webview_api_impls.dart @@ -0,0 +1,622 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/services.dart'; + +import 'android_webview.dart'; +import 'android_webview.pigeon.dart'; +import 'instance_manager.dart'; + +/// Host api implementation for [WebView]. +class WebViewHostApiImpl extends WebViewHostApi { + /// Constructs a [WebViewHostApiImpl]. + WebViewHostApiImpl({ + BinaryMessenger? binaryMessenger, + InstanceManager? instanceManager, + }) : super(binaryMessenger: binaryMessenger) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + /// Helper method to convert instances ids to objects. + Future createFromInstance(WebView instance) async { + final int? instanceId = instanceManager.tryAddInstance(instance); + if (instanceId != null) { + return create(instanceId, instance.useHybridComposition); + } + } + + /// Helper method to convert instances ids to objects. + Future disposeFromInstance(WebView instance) async { + final int? instanceId = instanceManager.removeInstance(instance); + if (instanceId != null) { + return dispose(instanceId); + } + } + + /// Helper method to convert instances ids to objects. + Future loadUrlFromInstance( + WebView instance, + String url, + Map headers, + ) { + return loadUrl(instanceManager.getInstanceId(instance)!, url, headers); + } + + /// Helper method to convert instances ids to objects. + Future getUrlFromInstance(WebView instance) { + return getUrl(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future canGoBackFromInstance(WebView instance) { + return canGoBack(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future canGoForwardFromInstance(WebView instance) { + return canGoForward(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future goBackFromInstance(WebView instance) { + return goBack(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future goForwardFromInstance(WebView instance) { + return goForward(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future reloadFromInstance(WebView instance) { + return reload(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future clearCacheFromInstance(WebView instance, bool includeDiskFiles) { + return clearCache( + instanceManager.getInstanceId(instance)!, + includeDiskFiles, + ); + } + + /// Helper method to convert instances ids to objects. + Future evaluateJavascriptFromInstance( + WebView instance, + String javascriptString, + ) { + return evaluateJavascript( + instanceManager.getInstanceId(instance)!, javascriptString); + } + + /// Helper method to convert instances ids to objects. + Future getTitleFromInstance(WebView instance) { + return getTitle(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future scrollToFromInstance(WebView instance, int x, int y) { + return scrollTo(instanceManager.getInstanceId(instance)!, x, y); + } + + /// Helper method to convert instances ids to objects. + Future scrollByFromInstance(WebView instance, int x, int y) { + return scrollBy(instanceManager.getInstanceId(instance)!, x, y); + } + + /// Helper method to convert instances ids to objects. + Future getScrollXFromInstance(WebView instance) { + return getScrollX(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future getScrollYFromInstance(WebView instance) { + return getScrollY(instanceManager.getInstanceId(instance)!); + } + + /// Helper method to convert instances ids to objects. + Future setWebViewClientFromInstance( + WebView instance, + WebViewClient webViewClient, + ) { + return setWebViewClient( + instanceManager.getInstanceId(instance)!, + instanceManager.getInstanceId(webViewClient)!, + ); + } + + /// Helper method to convert instances ids to objects. + Future addJavaScriptChannelFromInstance( + WebView instance, + JavaScriptChannel javaScriptChannel, + ) { + return addJavaScriptChannel( + instanceManager.getInstanceId(instance)!, + instanceManager.getInstanceId(javaScriptChannel)!, + ); + } + + /// Helper method to convert instances ids to objects. + Future removeJavaScriptChannelFromInstance( + WebView instance, + JavaScriptChannel javaScriptChannel, + ) { + return removeJavaScriptChannel( + instanceManager.getInstanceId(instance)!, + instanceManager.getInstanceId(javaScriptChannel)!, + ); + } + + /// Helper method to convert instances ids to objects. + Future setDownloadListenerFromInstance( + WebView instance, + DownloadListener listener, + ) { + return setDownloadListener( + instanceManager.getInstanceId(instance)!, + instanceManager.getInstanceId(listener)!, + ); + } + + /// Helper method to convert instances ids to objects. + Future setWebChromeClientFromInstance( + WebView instance, + WebChromeClient client, + ) { + return setWebChromeClient( + instanceManager.getInstanceId(instance)!, + instanceManager.getInstanceId(client)!, + ); + } +} + +/// Host api implementation for [WebSettings]. +class WebSettingsHostApiImpl extends WebSettingsHostApi { + /// Constructs a [WebSettingsHostApiImpl]. + WebSettingsHostApiImpl({ + BinaryMessenger? binaryMessenger, + InstanceManager? instanceManager, + }) : super(binaryMessenger: binaryMessenger) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + /// Helper method to convert instances ids to objects. + Future createFromInstance(WebSettings instance, WebView webView) async { + final int? instanceId = instanceManager.tryAddInstance(instance); + if (instanceId != null) { + return create( + instanceId, + instanceManager.getInstanceId(webView)!, + ); + } + } + + /// Helper method to convert instances ids to objects. + Future disposeFromInstance(WebSettings instance) async { + final int? instanceId = instanceManager.removeInstance(instance); + if (instanceId != null) { + return dispose(instanceId); + } + } + + /// Helper method to convert instances ids to objects. + Future setDomStorageEnabledFromInstance( + WebSettings instance, + bool flag, + ) { + return setDomStorageEnabled(instanceManager.getInstanceId(instance)!, flag); + } + + /// Helper method to convert instances ids to objects. + Future setJavaScriptCanOpenWindowsAutomaticallyFromInstance( + WebSettings instance, + bool flag, + ) { + return setJavaScriptCanOpenWindowsAutomatically( + instanceManager.getInstanceId(instance)!, + flag, + ); + } + + /// Helper method to convert instances ids to objects. + Future setSupportMultipleWindowsFromInstance( + WebSettings instance, + bool support, + ) { + return setSupportMultipleWindows( + instanceManager.getInstanceId(instance)!, support); + } + + /// Helper method to convert instances ids to objects. + Future setJavaScriptEnabledFromInstance( + WebSettings instance, + bool flag, + ) { + return setJavaScriptCanOpenWindowsAutomatically( + instanceManager.getInstanceId(instance)!, + flag, + ); + } + + /// Helper method to convert instances ids to objects. + Future setUserAgentStringFromInstance( + WebSettings instance, + String userAgentString, + ) { + return setUserAgentString( + instanceManager.getInstanceId(instance)!, + userAgentString, + ); + } + + /// Helper method to convert instances ids to objects. + Future setMediaPlaybackRequiresUserGestureFromInstance( + WebSettings instance, + bool require, + ) { + return setMediaPlaybackRequiresUserGesture( + instanceManager.getInstanceId(instance)!, + require, + ); + } + + /// Helper method to convert instances ids to objects. + Future setSupportZoomFromInstance( + WebSettings instance, + bool support, + ) { + return setSupportZoom(instanceManager.getInstanceId(instance)!, support); + } + + /// Helper method to convert instances ids to objects. + Future setLoadWithOverviewModeFromInstance( + WebSettings instance, + bool overview, + ) { + return setLoadWithOverviewMode( + instanceManager.getInstanceId(instance)!, + overview, + ); + } + + /// Helper method to convert instances ids to objects. + Future setUseWideViewPortFromInstance( + WebSettings instance, + bool use, + ) { + return setUseWideViewPort(instanceManager.getInstanceId(instance)!, use); + } + + /// Helper method to convert instances ids to objects. + Future setDisplayZoomControlsFromInstance( + WebSettings instance, + bool enabled, + ) { + return setDisplayZoomControls( + instanceManager.getInstanceId(instance)!, + enabled, + ); + } + + /// Helper method to convert instances ids to objects. + Future setBuiltInZoomControlsFromInstance( + WebSettings instance, + bool enabled, + ) { + return setBuiltInZoomControls( + instanceManager.getInstanceId(instance)!, + enabled, + ); + } +} + +/// Host api implementation for [JavaScriptChannel]. +class JavaScriptChannelHostApiImpl extends JavaScriptChannelHostApi { + /// Constructs a [JavaScriptChannelHostApiImpl]. + JavaScriptChannelHostApiImpl({ + BinaryMessenger? binaryMessenger, + InstanceManager? instanceManager, + }) : super(binaryMessenger: binaryMessenger) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + /// Helper method to convert instances ids to objects. + Future createFromInstance(JavaScriptChannel instance) async { + final int? instanceId = instanceManager.tryAddInstance(instance); + if (instanceId != null) { + return create(instanceId, instance.channelName); + } + } + + /// Helper method to convert instances ids to objects. + Future disposeFromInstance(JavaScriptChannel instance) async { + final int? instanceId = instanceManager.removeInstance(instance); + if (instanceId != null) { + return dispose(instanceId); + } + } +} + +/// Flutter api implementation for [JavaScriptChannel]. +class JavaScriptChannelFlutterApiImpl extends JavaScriptChannelFlutterApi { + /// Constructs a [JavaScriptChannelFlutterApiImpl]. + JavaScriptChannelHostApiImpl({InstanceManager? instanceManager}) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + @override + void postMessage(int instanceId, String message) { + final JavaScriptChannel instance = + instanceManager.getInstance(instanceId) as JavaScriptChannel; + instance.postMessage(message); + } +} + +/// Host api implementation for [WebViewClient]. +class WebViewClientHostApiImpl extends WebViewClientHostApi { + /// Constructs a [WebViewClientHostApiImpl]. + WebViewClientHostApiImpl({ + BinaryMessenger? binaryMessenger, + InstanceManager? instanceManager, + }) : super(binaryMessenger: binaryMessenger) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + /// Helper method to convert instances ids to objects. + Future createFromInstance(WebViewClient instance) async { + final int? instanceId = instanceManager.tryAddInstance(instance); + if (instanceId != null) { + return create(instanceId, instance.shouldOverrideUrlLoading); + } + } + + /// Helper method to convert instances ids to objects. + Future disposeFromInstance(WebViewClient instance) async { + final int? instanceId = instanceManager.removeInstance(instance); + if (instanceId != null) { + return dispose(instanceId); + } + } +} + +/// Flutter api implementation for [WebViewClient]. +class WebViewClientFlutterApiImpl extends WebViewClientFlutterApi { + /// Constructs a [WebViewClientFlutterApiImpl]. + WebViewClientFlutterApiImpl({InstanceManager? instanceManager}) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + @override + void onPageFinished(int instanceId, int webViewInstanceId, String url) { + final WebViewClient instance = + instanceManager.getInstance(instanceId) as WebViewClient; + instance.onPageFinished( + instanceManager.getInstance(webViewInstanceId) as WebView, + url, + ); + } + + @override + void onPageStarted(int instanceId, int webViewInstanceId, String url) { + final WebViewClient instance = + instanceManager.getInstance(instanceId) as WebViewClient; + instance.onPageStarted( + instanceManager.getInstance(webViewInstanceId) as WebView, + url, + ); + } + + @override + void onReceivedError( + int instanceId, + int webViewInstanceId, + int errorCode, + String description, + String failingUrl, + ) { + final WebViewClient instance = + instanceManager.getInstance(instanceId) as WebViewClient; + // ignore: deprecated_member_use_from_same_package + instance.onReceivedError( + instanceManager.getInstance(webViewInstanceId) as WebView, + errorCode, + description, + failingUrl, + ); + } + + @override + void onReceivedRequestError( + int instanceId, + int webViewInstanceId, + WebResourceRequestData request, + WebResourceErrorData error, + ) { + final WebViewClient instance = + instanceManager.getInstance(instanceId) as WebViewClient; + instance.onReceivedRequestError( + instanceManager.getInstance(webViewInstanceId) as WebView, + WebResourceRequest( + url: request.url!, + isForMainFrame: request.isForMainFrame!, + isRedirect: request.isRedirect, + hasGesture: request.hasGesture!, + method: request.method!, + requestHeaders: request.requestHeaders!.cast(), + ), + WebResourceError( + errorCode: error.errorCode!, + description: error.description!, + ), + ); + } + + @override + void requestLoading( + int instanceId, + int webViewInstanceId, + WebResourceRequestData request, + ) { + final WebViewClient instance = + instanceManager.getInstance(instanceId) as WebViewClient; + instance.requestLoading( + instanceManager.getInstance(webViewInstanceId) as WebView, + WebResourceRequest( + url: request.url!, + isForMainFrame: request.isForMainFrame!, + isRedirect: request.isRedirect, + hasGesture: request.hasGesture!, + method: request.method!, + requestHeaders: request.requestHeaders!.cast(), + ), + ); + } + + @override + void urlLoading( + int instanceId, + int webViewInstanceId, + String url, + ) { + final WebViewClient instance = + instanceManager.getInstance(instanceId) as WebViewClient; + instance.urlLoading( + instanceManager.getInstance(webViewInstanceId) as WebView, + url, + ); + } +} + +/// Host api implementation for [DownloadListener]. +class DownloadListenerHostApiImpl extends DownloadListenerHostApi { + /// Constructs a [DownloadListenerHostApiImpl]. + DownloadListenerHostApiImpl({ + BinaryMessenger? binaryMessenger, + InstanceManager? instanceManager, + }) : super(binaryMessenger: binaryMessenger) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + /// Helper method to convert instances ids to objects. + Future createFromInstance(DownloadListener instance) async { + final int? instanceId = instanceManager.tryAddInstance(instance); + if (instanceId != null) { + return create(instanceId); + } + } + + /// Helper method to convert instances ids to objects. + Future disposeFromInstance(DownloadListener instance) async { + final int? instanceId = instanceManager.removeInstance(instance); + if (instanceId != null) { + return dispose(instanceId); + } + } +} + +/// Flutter api implementation for [DownloadListener]. +class DownloadListenerFlutterApiImpl extends DownloadListenerFlutterApi { + /// Constructs a [DownloadListenerFlutterApiImpl]. + DownloadListenerFlutterApiImpl({InstanceManager? instanceManager}) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + @override + void onDownloadStart( + int instanceId, + String url, + String userAgent, + String contentDisposition, + String mimetype, + int contentLength, + ) { + final DownloadListener instance = + instanceManager.getInstance(instanceId) as DownloadListener; + instance.onDownloadStart( + url, + userAgent, + contentDisposition, + mimetype, + contentLength, + ); + } +} + +/// Host api implementation for [DownloadListener]. +class WebChromeClientHostApiImpl extends WebChromeClientHostApi { + /// Constructs a [WebChromeClientHostApiImpl]. + WebChromeClientHostApiImpl({ + BinaryMessenger? binaryMessenger, + InstanceManager? instanceManager, + }) : super(binaryMessenger: binaryMessenger) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + /// Helper method to convert instances ids to objects. + Future createFromInstance( + WebChromeClient instance, + WebViewClient webViewClient, + ) async { + final int? instanceId = instanceManager.tryAddInstance(instance); + if (instanceId != null) { + return create(instanceId, instanceManager.getInstanceId(webViewClient)!); + } + } + + /// Helper method to convert instances ids to objects. + Future disposeFromInstance(WebChromeClient instance) async { + final int? instanceId = instanceManager.removeInstance(instance); + if (instanceId != null) { + return dispose(instanceId); + } + } +} + +/// Flutter api implementation for [DownloadListener]. +class WebChromeClientFlutterApiImpl extends WebChromeClientFlutterApi { + /// Constructs a [DownloadListenerFlutterApiImpl]. + WebChromeClientFlutterApiImpl({InstanceManager? instanceManager}) { + this.instanceManager = instanceManager ?? InstanceManager.instance; + } + + /// Maintains instances stored to communicate with java objects. + late final InstanceManager instanceManager; + + @override + void onProgressChanged(int instanceId, int webViewInstanceId, int progress) { + final WebChromeClient instance = + instanceManager.getInstance(instanceId) as WebChromeClient; + instance.onProgressChanged( + instanceManager.getInstance(webViewInstanceId) as WebView, + progress, + ); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/lib/src/instance_manager.dart b/packages/webview_flutter/webview_flutter_android/lib/src/instance_manager.dart new file mode 100644 index 000000000000..94d8bc3253eb --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/src/instance_manager.dart @@ -0,0 +1,52 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Maintains instances stored to communicate with java objects. +class InstanceManager { + Map _instanceIdsToInstances = {}; + Map _instancesToInstanceIds = {}; + + int _nextInstanceId = 0; + + /// Global instance of [InstanceManager]. + static final InstanceManager instance = InstanceManager(); + + /// Attempt to add a new instance. + /// + /// Returns new if [instance] has already been added. Otherwise, it is added + /// with a new instance id. + int? tryAddInstance(Object instance) { + if (_instancesToInstanceIds.containsKey(instance)) { + return null; + } + + final int instanceId = _nextInstanceId++; + _instancesToInstanceIds[instance] = instanceId; + _instanceIdsToInstances[instanceId] = instance; + return instanceId; + } + + /// Remove the instance from the manager. + /// + /// Returns null if the instance is removed. Otherwise, return the instanceId + /// of the removed instance. + int? removeInstance(Object instance) { + final int? instanceId = _instancesToInstanceIds[instance]; + if (instanceId != null) { + _instancesToInstanceIds.remove(instance); + _instanceIdsToInstances.remove(instanceId); + } + return instanceId; + } + + /// Retrieve the Object paired with instanceId. + Object? getInstance(int instanceId) { + return _instanceIdsToInstances[instanceId]; + } + + /// Retrieve the instanceId paired with instance. + int? getInstanceId(Object instance) { + return _instancesToInstanceIds[instance]; + } +} diff --git a/packages/webview_flutter/webview_flutter_android/lib/webview_android.dart b/packages/webview_flutter/webview_flutter_android/lib/webview_android.dart new file mode 100644 index 000000000000..a48e457d55ad --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/webview_android.dart @@ -0,0 +1,62 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +/// Builds an Android webview. +/// +/// This is used as the default implementation for [WebView.platform] on Android. It uses +/// an [AndroidView] to embed the webview in the widget hierarchy, and uses a method channel to +/// communicate with the platform code. +class AndroidWebView implements WebViewPlatform { + @override + Widget build({ + required BuildContext context, + required CreationParams creationParams, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + }) { + assert(webViewPlatformCallbacksHandler != null); + return GestureDetector( + // We prevent text selection by intercepting the long press event. + // This is a temporary stop gap due to issues with text selection on Android: + // https://github.com/flutter/flutter/issues/24585 - the text selection + // dialog is not responding to touch events. + // https://github.com/flutter/flutter/issues/24584 - the text selection + // handles are not showing. + // TODO(amirh): remove this when the issues above are fixed. + onLongPress: () {}, + excludeFromSemantics: true, + child: AndroidView( + viewType: 'plugins.flutter.io/webview', + onPlatformViewCreated: (int id) { + if (onWebViewPlatformCreated == null) { + return; + } + onWebViewPlatformCreated(MethodChannelWebViewPlatform( + id, + webViewPlatformCallbacksHandler, + javascriptChannelRegistry, + )); + }, + gestureRecognizers: gestureRecognizers, + layoutDirection: Directionality.maybeOf(context) ?? TextDirection.rtl, + creationParams: + MethodChannelWebViewPlatform.creationParamsToMap(creationParams), + creationParamsCodec: const StandardMessageCodec(), + ), + ); + } + + @override + Future clearCookies() => MethodChannelWebViewPlatform.clearCookies(); +} diff --git a/packages/webview_flutter/webview_flutter_android/lib/webview_surface_android.dart b/packages/webview_flutter/webview_flutter_android/lib/webview_surface_android.dart new file mode 100644 index 000000000000..6beae105e2e5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/lib/webview_surface_android.dart @@ -0,0 +1,78 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'webview_android.dart'; + +/// Android [WebViewPlatform] that uses [AndroidViewSurface] to build the [WebView] widget. +/// +/// To use this, set [WebView.platform] to an instance of this class. +/// +/// This implementation uses hybrid composition to render the [WebView] on +/// Android. It solves multiple issues related to accessibility and interaction +/// with the [WebView] at the cost of some performance on Android versions below +/// 10. See https://github.com/flutter/flutter/wiki/Hybrid-Composition for more +/// information. +class SurfaceAndroidWebView extends AndroidWebView { + @override + Widget build({ + required BuildContext context, + required CreationParams creationParams, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + }) { + assert(webViewPlatformCallbacksHandler != null); + return PlatformViewLink( + viewType: 'plugins.flutter.io/webview', + surfaceFactory: ( + BuildContext context, + PlatformViewController controller, + ) { + return AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: gestureRecognizers ?? + const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ); + }, + onCreatePlatformView: (PlatformViewCreationParams params) { + return PlatformViewsService.initSurfaceAndroidView( + id: params.id, + viewType: 'plugins.flutter.io/webview', + // WebView content is not affected by the Android view's layout direction, + // we explicitly set it here so that the widget doesn't require an ambient + // directionality. + layoutDirection: TextDirection.rtl, + creationParams: MethodChannelWebViewPlatform.creationParamsToMap( + creationParams, + usesHybridComposition: true, + ), + creationParamsCodec: const StandardMessageCodec(), + ) + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..addOnPlatformViewCreatedListener((int id) { + if (onWebViewPlatformCreated == null) { + return; + } + onWebViewPlatformCreated( + MethodChannelWebViewPlatform( + id, + webViewPlatformCallbacksHandler, + javascriptChannelRegistry, + ), + ); + }) + ..create(); + }, + ); + } +} diff --git a/packages/webview_flutter/webview_flutter_android/pigeons/android_webview.dart b/packages/webview_flutter/webview_flutter_android/pigeons/android_webview.dart new file mode 100644 index 000000000000..1877c1b91feb --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/pigeons/android_webview.dart @@ -0,0 +1,181 @@ +// Copyright 2013 The Flutter 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:pigeon/pigeon.dart'; + +class WebResourceRequestData { + String? url; + bool? isForMainFrame; + bool? isRedirect; + bool? hasGesture; + String? method; + Map? requestHeaders; +} + +class WebResourceErrorData { + int? errorCode; + String? description; +} + +@HostApi(dartHostTestHandler: 'TestWebViewHostApi') +abstract class WebViewHostApi { + void create(int instanceId, bool useHybridComposition); + + void dispose(int instanceId); + + void loadUrl( + int instanceId, + String url, + Map headers, + ); + + String getUrl(int instanceId); + + bool canGoBack(int instanceId); + + bool canGoForward(int instanceId); + + void goBack(int instanceId); + + void goForward(int instanceId); + + void reload(int instanceId); + + void clearCache(int instanceId, bool includeDiskFiles); + + @async + String evaluateJavascript( + int instanceId, + String javascriptString, + ); + + String getTitle(int instanceId); + + void scrollTo(int instanceId, int x, int y); + + void scrollBy(int instanceId, int x, int y); + + int getScrollX(int instanceId); + + int getScrollY(int instanceId); + + void setWebContentsDebuggingEnabled(bool enabled); + + void setWebViewClient(int instanceId, int webViewClientInstanceId); + + void addJavaScriptChannel(int instanceId, int javaScriptChannelInstanceId); + + void removeJavaScriptChannel(int instanceId, int javaScriptChannelInstanceId); + + void setDownloadListener(int instanceId, int listenerInstanceId); + + void setWebChromeClient(int instanceId, int clientInstanceId); +} + +@HostApi(dartHostTestHandler: 'TestWebSettingsHostApi') +abstract class WebSettingsHostApi { + void create(int instanceId, int webViewInstanceId); + + void dispose(int instanceId); + + void setDomStorageEnabled(int instanceId, bool flag); + + void setJavaScriptCanOpenWindowsAutomatically(int instanceId, bool flag); + + void setSupportMultipleWindows(int instanceId, bool support); + + void setJavaScriptEnabled(int instanceId, bool flag); + + void setUserAgentString(int instanceId, String userAgentString); + + void setMediaPlaybackRequiresUserGesture(int instanceId, bool require); + + void setSupportZoom(int instanceId, bool support); + + void setLoadWithOverviewMode(int instanceId, bool overview); + + void setUseWideViewPort(int instanceId, bool use); + + void setDisplayZoomControls(int instanceId, bool enabled); + + void setBuiltInZoomControls(int instanceId, bool enabled); +} + +@HostApi(dartHostTestHandler: 'TestJavaScriptChannelHostApi') +abstract class JavaScriptChannelHostApi { + void create(int instanceId, String channelName); + + void dispose(int instanceId); +} + +@FlutterApi() +abstract class JavaScriptChannelFlutterApi { + void postMessage(int instanceId, String message); +} + +@HostApi(dartHostTestHandler: 'TestWebViewClientHostApi') +abstract class WebViewClientHostApi { + void create(int instanceId, bool shouldOverrideUrlLoading); + + void dispose(int instanceId); +} + +@FlutterApi() +abstract class WebViewClientFlutterApi { + void onPageStarted(int instanceId, int webViewInstanceId, String url); + + void onPageFinished(int instanceId, int webViewInstanceId, String url); + + void onReceivedRequestError( + int instanceId, + int webViewInstanceId, + WebResourceRequestData request, + WebResourceErrorData error, + ); + + void onReceivedError( + int instanceId, + int webViewInstanceId, + int errorCode, + String description, + String failingUrl, + ); + + void requestLoading( + int instanceId, + int webViewInstanceId, + WebResourceRequestData request, + ); + + void urlLoading(int instanceId, int webViewInstanceId, String url); +} + +@HostApi(dartHostTestHandler: 'TestDownloadListenerHostApi') +abstract class DownloadListenerHostApi { + void create(int instanceId); + void dispose(int instanceId); +} + +@FlutterApi() +abstract class DownloadListenerFlutterApi { + void onDownloadStart( + int instanceId, + String url, + String userAgent, + String contentDisposition, + String mimetype, + int contentLength, + ); +} + +@HostApi(dartHostTestHandler: 'TestWebChromeClientHostApi') +abstract class WebChromeClientHostApi { + void create(int instanceId, int webViewClientInstanceId); + void dispose(int instanceId); +} + +@FlutterApi() +abstract class WebChromeClientFlutterApi { + void onProgressChanged(int instanceId, int webViewInstanceId, int progress); +} diff --git a/packages/webview_flutter/webview_flutter_android/pubspec.yaml b/packages/webview_flutter/webview_flutter_android/pubspec.yaml new file mode 100644 index 000000000000..ac208a09ebc0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/pubspec.yaml @@ -0,0 +1,31 @@ +name: webview_flutter_android +description: A Flutter plugin that provides a WebView widget on Android. +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_android +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 +version: 2.2.0 + +environment: + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" + +flutter: + plugin: + implements: webview_flutter + platforms: + android: + package: io.flutter.plugins.webviewflutter + pluginClass: WebViewFlutterPlugin + +dependencies: + flutter: + sdk: flutter + webview_flutter_platform_interface: ^1.2.0 + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + pigeon: 1.0.7 + pedantic: ^1.10.0 + diff --git a/packages/webview_flutter/webview_flutter_android/test/android_webview.pigeon.dart b/packages/webview_flutter/webview_flutter_android/test/android_webview.pigeon.dart new file mode 100644 index 000000000000..70aa53ca2610 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/test/android_webview.pigeon.dart @@ -0,0 +1,1000 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Autogenerated from Pigeon (v1.0.7), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, avoid_relative_lib_imports +// @dart = 2.12 +import 'dart:async'; +import 'dart:typed_data' show Uint8List, Int32List, Int64List, Float64List; +import 'package:flutter/foundation.dart' show WriteBuffer, ReadBuffer; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../lib/src/android_webview.pigeon.dart'; + +class _TestWebViewHostApiCodec extends StandardMessageCodec { + const _TestWebViewHostApiCodec(); +} + +abstract class TestWebViewHostApi { + static const MessageCodec codec = _TestWebViewHostApiCodec(); + + void create(int instanceId, bool useHybridComposition); + void dispose(int instanceId); + void loadUrl(int instanceId, String url, Map headers); + String getUrl(int instanceId); + bool canGoBack(int instanceId); + bool canGoForward(int instanceId); + void goBack(int instanceId); + void goForward(int instanceId); + void reload(int instanceId); + void clearCache(int instanceId, bool includeDiskFiles); + Future evaluateJavascript(int instanceId, String javascriptString); + String getTitle(int instanceId); + void scrollTo(int instanceId, int x, int y); + void scrollBy(int instanceId, int x, int y); + int getScrollX(int instanceId); + int getScrollY(int instanceId); + void setWebContentsDebuggingEnabled(bool enabled); + void setWebViewClient(int instanceId, int webViewClientInstanceId); + void addJavaScriptChannel(int instanceId, int javaScriptChannelInstanceId); + void removeJavaScriptChannel(int instanceId, int javaScriptChannelInstanceId); + void setDownloadListener(int instanceId, int listenerInstanceId); + void setWebChromeClient(int instanceId, int clientInstanceId); + static void setup(TestWebViewHostApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.create', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.create was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.create was null, expected non-null int.'); + final bool? arg_useHybridComposition = args[1] as bool?; + assert(arg_useHybridComposition != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.create was null, expected non-null bool.'); + api.create(arg_instanceId!, arg_useHybridComposition!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.dispose', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.dispose was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.dispose was null, expected non-null int.'); + api.dispose(arg_instanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.loadUrl', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.loadUrl was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.loadUrl was null, expected non-null int.'); + final String? arg_url = args[1] as String?; + assert(arg_url != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.loadUrl was null, expected non-null String.'); + final Map? arg_headers = + args[2] as Map?; + assert(arg_headers != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.loadUrl was null, expected non-null Map.'); + api.loadUrl(arg_instanceId!, arg_url!, arg_headers!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getUrl', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getUrl was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getUrl was null, expected non-null int.'); + final String output = api.getUrl(arg_instanceId!); + return {'result': output}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.canGoBack', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.canGoBack was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.canGoBack was null, expected non-null int.'); + final bool output = api.canGoBack(arg_instanceId!); + return {'result': output}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.canGoForward', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.canGoForward was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.canGoForward was null, expected non-null int.'); + final bool output = api.canGoForward(arg_instanceId!); + return {'result': output}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.goBack', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.goBack was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.goBack was null, expected non-null int.'); + api.goBack(arg_instanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.goForward', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.goForward was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.goForward was null, expected non-null int.'); + api.goForward(arg_instanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.reload', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.reload was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.reload was null, expected non-null int.'); + api.reload(arg_instanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.clearCache', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.clearCache was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.clearCache was null, expected non-null int.'); + final bool? arg_includeDiskFiles = args[1] as bool?; + assert(arg_includeDiskFiles != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.clearCache was null, expected non-null bool.'); + api.clearCache(arg_instanceId!, arg_includeDiskFiles!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.evaluateJavascript', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.evaluateJavascript was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.evaluateJavascript was null, expected non-null int.'); + final String? arg_javascriptString = args[1] as String?; + assert(arg_javascriptString != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.evaluateJavascript was null, expected non-null String.'); + final String output = await api.evaluateJavascript( + arg_instanceId!, arg_javascriptString!); + return {'result': output}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getTitle', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getTitle was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getTitle was null, expected non-null int.'); + final String output = api.getTitle(arg_instanceId!); + return {'result': output}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.scrollTo', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollTo was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollTo was null, expected non-null int.'); + final int? arg_x = args[1] as int?; + assert(arg_x != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollTo was null, expected non-null int.'); + final int? arg_y = args[2] as int?; + assert(arg_y != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollTo was null, expected non-null int.'); + api.scrollTo(arg_instanceId!, arg_x!, arg_y!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.scrollBy', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollBy was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollBy was null, expected non-null int.'); + final int? arg_x = args[1] as int?; + assert(arg_x != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollBy was null, expected non-null int.'); + final int? arg_y = args[2] as int?; + assert(arg_y != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.scrollBy was null, expected non-null int.'); + api.scrollBy(arg_instanceId!, arg_x!, arg_y!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getScrollX', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getScrollX was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getScrollX was null, expected non-null int.'); + final int output = api.getScrollX(arg_instanceId!); + return {'result': output}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.getScrollY', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getScrollY was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.getScrollY was null, expected non-null int.'); + final int output = api.getScrollY(arg_instanceId!); + return {'result': output}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setWebContentsDebuggingEnabled', + codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebContentsDebuggingEnabled was null.'); + final List args = (message as List?)!; + final bool? arg_enabled = args[0] as bool?; + assert(arg_enabled != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebContentsDebuggingEnabled was null, expected non-null bool.'); + api.setWebContentsDebuggingEnabled(arg_enabled!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setWebViewClient', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebViewClient was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebViewClient was null, expected non-null int.'); + final int? arg_webViewClientInstanceId = args[1] as int?; + assert(arg_webViewClientInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebViewClient was null, expected non-null int.'); + api.setWebViewClient(arg_instanceId!, arg_webViewClientInstanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.addJavaScriptChannel', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.addJavaScriptChannel was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.addJavaScriptChannel was null, expected non-null int.'); + final int? arg_javaScriptChannelInstanceId = args[1] as int?; + assert(arg_javaScriptChannelInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.addJavaScriptChannel was null, expected non-null int.'); + api.addJavaScriptChannel( + arg_instanceId!, arg_javaScriptChannelInstanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.removeJavaScriptChannel', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.removeJavaScriptChannel was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.removeJavaScriptChannel was null, expected non-null int.'); + final int? arg_javaScriptChannelInstanceId = args[1] as int?; + assert(arg_javaScriptChannelInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.removeJavaScriptChannel was null, expected non-null int.'); + api.removeJavaScriptChannel( + arg_instanceId!, arg_javaScriptChannelInstanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setDownloadListener', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setDownloadListener was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setDownloadListener was null, expected non-null int.'); + final int? arg_listenerInstanceId = args[1] as int?; + assert(arg_listenerInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setDownloadListener was null, expected non-null int.'); + api.setDownloadListener(arg_instanceId!, arg_listenerInstanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewHostApi.setWebChromeClient', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebChromeClient was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebChromeClient was null, expected non-null int.'); + final int? arg_clientInstanceId = args[1] as int?; + assert(arg_clientInstanceId != null, + 'Argument for dev.flutter.pigeon.WebViewHostApi.setWebChromeClient was null, expected non-null int.'); + api.setWebChromeClient(arg_instanceId!, arg_clientInstanceId!); + return {}; + }); + } + } + } +} + +class _TestWebSettingsHostApiCodec extends StandardMessageCodec { + const _TestWebSettingsHostApiCodec(); +} + +abstract class TestWebSettingsHostApi { + static const MessageCodec codec = _TestWebSettingsHostApiCodec(); + + void create(int instanceId, int webViewInstanceId); + void dispose(int instanceId); + void setDomStorageEnabled(int instanceId, bool flag); + void setJavaScriptCanOpenWindowsAutomatically(int instanceId, bool flag); + void setSupportMultipleWindows(int instanceId, bool support); + void setJavaScriptEnabled(int instanceId, bool flag); + void setUserAgentString(int instanceId, String userAgentString); + void setMediaPlaybackRequiresUserGesture(int instanceId, bool require); + void setSupportZoom(int instanceId, bool support); + void setLoadWithOverviewMode(int instanceId, bool overview); + void setUseWideViewPort(int instanceId, bool use); + void setDisplayZoomControls(int instanceId, bool enabled); + void setBuiltInZoomControls(int instanceId, bool enabled); + static void setup(TestWebSettingsHostApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.create', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.create was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.create was null, expected non-null int.'); + final int? arg_webViewInstanceId = args[1] as int?; + assert(arg_webViewInstanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.create was null, expected non-null int.'); + api.create(arg_instanceId!, arg_webViewInstanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.dispose', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.dispose was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.dispose was null, expected non-null int.'); + api.dispose(arg_instanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setDomStorageEnabled', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setDomStorageEnabled was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setDomStorageEnabled was null, expected non-null int.'); + final bool? arg_flag = args[1] as bool?; + assert(arg_flag != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setDomStorageEnabled was null, expected non-null bool.'); + api.setDomStorageEnabled(arg_instanceId!, arg_flag!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptCanOpenWindowsAutomatically', + codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptCanOpenWindowsAutomatically was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptCanOpenWindowsAutomatically was null, expected non-null int.'); + final bool? arg_flag = args[1] as bool?; + assert(arg_flag != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptCanOpenWindowsAutomatically was null, expected non-null bool.'); + api.setJavaScriptCanOpenWindowsAutomatically( + arg_instanceId!, arg_flag!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setSupportMultipleWindows', + codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setSupportMultipleWindows was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setSupportMultipleWindows was null, expected non-null int.'); + final bool? arg_support = args[1] as bool?; + assert(arg_support != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setSupportMultipleWindows was null, expected non-null bool.'); + api.setSupportMultipleWindows(arg_instanceId!, arg_support!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptEnabled', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptEnabled was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptEnabled was null, expected non-null int.'); + final bool? arg_flag = args[1] as bool?; + assert(arg_flag != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setJavaScriptEnabled was null, expected non-null bool.'); + api.setJavaScriptEnabled(arg_instanceId!, arg_flag!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setUserAgentString', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setUserAgentString was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setUserAgentString was null, expected non-null int.'); + final String? arg_userAgentString = args[1] as String?; + assert(arg_userAgentString != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setUserAgentString was null, expected non-null String.'); + api.setUserAgentString(arg_instanceId!, arg_userAgentString!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setMediaPlaybackRequiresUserGesture', + codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setMediaPlaybackRequiresUserGesture was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setMediaPlaybackRequiresUserGesture was null, expected non-null int.'); + final bool? arg_require = args[1] as bool?; + assert(arg_require != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setMediaPlaybackRequiresUserGesture was null, expected non-null bool.'); + api.setMediaPlaybackRequiresUserGesture( + arg_instanceId!, arg_require!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setSupportZoom', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setSupportZoom was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setSupportZoom was null, expected non-null int.'); + final bool? arg_support = args[1] as bool?; + assert(arg_support != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setSupportZoom was null, expected non-null bool.'); + api.setSupportZoom(arg_instanceId!, arg_support!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setLoadWithOverviewMode', + codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setLoadWithOverviewMode was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setLoadWithOverviewMode was null, expected non-null int.'); + final bool? arg_overview = args[1] as bool?; + assert(arg_overview != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setLoadWithOverviewMode was null, expected non-null bool.'); + api.setLoadWithOverviewMode(arg_instanceId!, arg_overview!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setUseWideViewPort', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setUseWideViewPort was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setUseWideViewPort was null, expected non-null int.'); + final bool? arg_use = args[1] as bool?; + assert(arg_use != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setUseWideViewPort was null, expected non-null bool.'); + api.setUseWideViewPort(arg_instanceId!, arg_use!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setDisplayZoomControls', + codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setDisplayZoomControls was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setDisplayZoomControls was null, expected non-null int.'); + final bool? arg_enabled = args[1] as bool?; + assert(arg_enabled != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setDisplayZoomControls was null, expected non-null bool.'); + api.setDisplayZoomControls(arg_instanceId!, arg_enabled!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebSettingsHostApi.setBuiltInZoomControls', + codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setBuiltInZoomControls was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setBuiltInZoomControls was null, expected non-null int.'); + final bool? arg_enabled = args[1] as bool?; + assert(arg_enabled != null, + 'Argument for dev.flutter.pigeon.WebSettingsHostApi.setBuiltInZoomControls was null, expected non-null bool.'); + api.setBuiltInZoomControls(arg_instanceId!, arg_enabled!); + return {}; + }); + } + } + } +} + +class _TestJavaScriptChannelHostApiCodec extends StandardMessageCodec { + const _TestJavaScriptChannelHostApiCodec(); +} + +abstract class TestJavaScriptChannelHostApi { + static const MessageCodec codec = + _TestJavaScriptChannelHostApiCodec(); + + void create(int instanceId, String channelName); + void dispose(int instanceId); + static void setup(TestJavaScriptChannelHostApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.JavaScriptChannelHostApi.create', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelHostApi.create was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelHostApi.create was null, expected non-null int.'); + final String? arg_channelName = args[1] as String?; + assert(arg_channelName != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelHostApi.create was null, expected non-null String.'); + api.create(arg_instanceId!, arg_channelName!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.JavaScriptChannelHostApi.dispose', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelHostApi.dispose was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.JavaScriptChannelHostApi.dispose was null, expected non-null int.'); + api.dispose(arg_instanceId!); + return {}; + }); + } + } + } +} + +class _TestWebViewClientHostApiCodec extends StandardMessageCodec { + const _TestWebViewClientHostApiCodec(); +} + +abstract class TestWebViewClientHostApi { + static const MessageCodec codec = _TestWebViewClientHostApiCodec(); + + void create(int instanceId, bool shouldOverrideUrlLoading); + void dispose(int instanceId); + static void setup(TestWebViewClientHostApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientHostApi.create', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientHostApi.create was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientHostApi.create was null, expected non-null int.'); + final bool? arg_shouldOverrideUrlLoading = args[1] as bool?; + assert(arg_shouldOverrideUrlLoading != null, + 'Argument for dev.flutter.pigeon.WebViewClientHostApi.create was null, expected non-null bool.'); + api.create(arg_instanceId!, arg_shouldOverrideUrlLoading!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebViewClientHostApi.dispose', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebViewClientHostApi.dispose was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebViewClientHostApi.dispose was null, expected non-null int.'); + api.dispose(arg_instanceId!); + return {}; + }); + } + } + } +} + +class _TestDownloadListenerHostApiCodec extends StandardMessageCodec { + const _TestDownloadListenerHostApiCodec(); +} + +abstract class TestDownloadListenerHostApi { + static const MessageCodec codec = + _TestDownloadListenerHostApiCodec(); + + void create(int instanceId); + void dispose(int instanceId); + static void setup(TestDownloadListenerHostApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DownloadListenerHostApi.create', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.DownloadListenerHostApi.create was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.DownloadListenerHostApi.create was null, expected non-null int.'); + api.create(arg_instanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.DownloadListenerHostApi.dispose', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.DownloadListenerHostApi.dispose was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.DownloadListenerHostApi.dispose was null, expected non-null int.'); + api.dispose(arg_instanceId!); + return {}; + }); + } + } + } +} + +class _TestWebChromeClientHostApiCodec extends StandardMessageCodec { + const _TestWebChromeClientHostApiCodec(); +} + +abstract class TestWebChromeClientHostApi { + static const MessageCodec codec = _TestWebChromeClientHostApiCodec(); + + void create(int instanceId, int webViewClientInstanceId); + void dispose(int instanceId); + static void setup(TestWebChromeClientHostApi? api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebChromeClientHostApi.create', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebChromeClientHostApi.create was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebChromeClientHostApi.create was null, expected non-null int.'); + final int? arg_webViewClientInstanceId = args[1] as int?; + assert(arg_webViewClientInstanceId != null, + 'Argument for dev.flutter.pigeon.WebChromeClientHostApi.create was null, expected non-null int.'); + api.create(arg_instanceId!, arg_webViewClientInstanceId!); + return {}; + }); + } + } + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.WebChromeClientHostApi.dispose', codec); + if (api == null) { + channel.setMockMessageHandler(null); + } else { + channel.setMockMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.WebChromeClientHostApi.dispose was null.'); + final List args = (message as List?)!; + final int? arg_instanceId = args[0] as int?; + assert(arg_instanceId != null, + 'Argument for dev.flutter.pigeon.WebChromeClientHostApi.dispose was null, expected non-null int.'); + api.dispose(arg_instanceId!); + return {}; + }); + } + } + } +} diff --git a/packages/webview_flutter/webview_flutter_android/test/android_webview_test.dart b/packages/webview_flutter/webview_flutter_android/test/android_webview_test.dart new file mode 100644 index 000000000000..462670abdb83 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/test/android_webview_test.dart @@ -0,0 +1,373 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_android/src/android_webview.dart'; +import 'package:webview_flutter_android/src/android_webview_api_impls.dart'; +import 'package:webview_flutter_android/src/instance_manager.dart'; + +import 'android_webview.pigeon.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Android WebView', () { + group('$WebView', () { + setUpAll(() { + TestWebViewHostApi.setup(TestWebViewHostApiImpl()); + }); + + setUp(() { + WebView.api = WebViewHostApiImpl(instanceManager: InstanceManager()); + }); + + test('create', () { + final WebView webView = WebView(); + expect(WebView.api.instanceManager.getInstanceId(webView), isNotNull); + }); + }); + + group('$WebSettings', () { + setUpAll(() { + TestWebViewHostApi.setup(TestWebViewHostApiImpl()); + TestWebSettingsHostApi.setup(TestWebSettingsHostApiImpl()); + }); + + setUp(() { + final InstanceManager instanceManager = InstanceManager(); + WebView.api = WebViewHostApiImpl(instanceManager: instanceManager); + WebSettings.api = WebSettingsHostApiImpl( + instanceManager: instanceManager, + ); + }); + + test('create', () { + final WebView webView = WebView(); + final WebSettings webSettings = WebSettings(webView); + expect( + WebSettings.api.instanceManager.getInstanceId(webSettings), + isNotNull, + ); + }); + }); + + group('$JavaScriptChannel', () { + setUpAll(() { + TestWebViewHostApi.setup(TestWebViewHostApiImpl()); + TestJavaScriptChannelHostApi.setup(TestJavaScriptChannelHostApiImpl()); + }); + + setUp(() { + final InstanceManager instanceManager = InstanceManager(); + WebView.api = WebViewHostApiImpl(instanceManager: instanceManager); + JavaScriptChannel.api = JavaScriptChannelHostApiImpl( + instanceManager: instanceManager, + ); + }); + + test('create', () { + final WebView webView = WebView(); + final JavaScriptChannel channel = TestJavaScriptChannel('myChannel'); + + webView.addJavaScriptChannel(channel); + expect( + JavaScriptChannel.api.instanceManager.getInstanceId(channel), + isNotNull, + ); + + webView.removeJavaScriptChannel(channel); + expect( + JavaScriptChannel.api.instanceManager.getInstanceId(channel), + isNull, + ); + }); + }); + + group('$WebViewClient', () { + setUpAll(() { + TestWebViewHostApi.setup(TestWebViewHostApiImpl()); + TestWebViewClientHostApi.setup(TestWebViewClientHostApiImpl()); + }); + + setUp(() { + final InstanceManager instanceManager = InstanceManager(); + WebView.api = WebViewHostApiImpl(instanceManager: instanceManager); + WebViewClient.api = WebViewClientHostApiImpl( + instanceManager: instanceManager, + ); + }); + + test('create', () { + final WebView webView = WebView(); + final WebViewClient webViewClient1 = TestWebViewClient(); + final WebViewClient webViewClient2 = TestWebViewClient(); + + webView.setWebViewClient(webViewClient1); + expect( + WebViewClient.api.instanceManager.getInstanceId(webViewClient1), + isNotNull, + ); + + webView.setWebViewClient(webViewClient2); + expect( + WebViewClient.api.instanceManager.getInstanceId(webViewClient1), + isNull, + ); + }); + }); + + group('$DownloadListener', () { + setUpAll(() { + TestWebViewHostApi.setup(TestWebViewHostApiImpl()); + TestDownloadListenerHostApi.setup(TestDownloadListenerHostApiImpl()); + }); + + setUp(() { + final InstanceManager instanceManager = InstanceManager(); + WebView.api = WebViewHostApiImpl(instanceManager: instanceManager); + DownloadListener.api = DownloadListenerHostApiImpl( + instanceManager: instanceManager, + ); + }); + + test('create', () { + final WebView webView = WebView(); + final DownloadListener downloadListener1 = TestDownloadListener(); + final DownloadListener downloadListener2 = TestDownloadListener(); + + webView.setDownloadListener(downloadListener1); + expect( + DownloadListener.api.instanceManager.getInstanceId(downloadListener1), + isNotNull, + ); + + webView.setDownloadListener(downloadListener2); + expect( + DownloadListener.api.instanceManager.getInstanceId(downloadListener1), + isNull, + ); + }); + }); + + group('$WebChromeClient', () { + setUpAll(() { + TestWebViewHostApi.setup(TestWebViewHostApiImpl()); + TestWebViewClientHostApi.setup(TestWebViewClientHostApiImpl()); + TestWebChromeClientHostApi.setup(TestWebChromeClientHostApiImpl()); + }); + + setUp(() { + final InstanceManager instanceManager = InstanceManager(); + WebView.api = WebViewHostApiImpl(instanceManager: instanceManager); + WebViewClient.api = WebViewClientHostApiImpl( + instanceManager: instanceManager, + ); + WebChromeClient.api = WebChromeClientHostApiImpl( + instanceManager: instanceManager, + ); + }); + + test('create', () { + final WebView webView = WebView(); + webView.setWebViewClient(TestWebViewClient()); + + final WebChromeClient webChromeClient1 = TestWebChromeClient(); + final WebChromeClient webChromeClient2 = TestWebChromeClient(); + + webView.setWebChromeClient(webChromeClient1); + expect( + WebChromeClient.api.instanceManager.getInstanceId(webChromeClient1), + isNotNull, + ); + + webView.setWebChromeClient(webChromeClient2); + expect( + WebChromeClient.api.instanceManager.getInstanceId(webChromeClient1), + isNull, + ); + }); + }); + }); +} + +class TestJavaScriptChannel extends JavaScriptChannel { + TestJavaScriptChannel(String channelName) : super(channelName); + + @override + void postMessage(String message) {} +} + +class TestWebViewClient extends WebViewClient {} + +class TestDownloadListener extends DownloadListener { + @override + void onDownloadStart( + String url, + String userAgent, + String contentDisposition, + String mimetype, + int contentLength, + ) {} +} + +class TestWebChromeClient extends WebChromeClient { + @override + void onProgressChanged(WebView webView, int progress) {} +} + +class TestWebViewHostApiImpl extends TestWebViewHostApi { + @override + void addJavaScriptChannel(int instanceId, int javaScriptChannelInstanceId) {} + + @override + bool canGoBack(int instanceId) { + throw UnimplementedError(); + } + + @override + bool canGoForward(int instanceId) { + throw UnimplementedError(); + } + + @override + void clearCache(int instanceId, bool includeDiskFiles) {} + + @override + void create(int instanceId, bool useHybridComposition) {} + + @override + void dispose(int instanceId) {} + + @override + Future evaluateJavascript(int instanceId, String javascriptString) { + throw UnimplementedError(); + } + + @override + int getScrollX(int instanceId) { + throw UnimplementedError(); + } + + @override + int getScrollY(int instanceId) { + throw UnimplementedError(); + } + + @override + String getTitle(int instanceId) { + throw UnimplementedError(); + } + + @override + String getUrl(int instanceId) { + throw UnimplementedError(); + } + + @override + void goBack(int instanceId) {} + + @override + void goForward(int instanceId) {} + + @override + void loadUrl(int instanceId, String url, Map headers) {} + + @override + void reload(int instanceId) {} + + @override + void removeJavaScriptChannel( + int instanceId, int javaScriptChannelInstanceId) {} + + @override + void scrollBy(int instanceId, int x, int y) {} + + @override + void scrollTo(int instanceId, int x, int y) {} + + @override + void setDownloadListener(int instanceId, int listenerInstanceId) {} + + @override + void setWebContentsDebuggingEnabled(bool enabled) {} + + @override + void setWebViewClient(int instanceId, int webViewClientInstanceId) {} + + @override + void setWebChromeClient(int instanceId, int clientInstanceId) {} +} + +class TestWebSettingsHostApiImpl extends TestWebSettingsHostApi { + @override + void create(int instanceId, int webViewInstanceId) {} + + @override + void dispose(int instanceId) {} + + @override + void setBuiltInZoomControls(int instanceId, bool enabled) {} + + @override + void setDisplayZoomControls(int instanceId, bool enabled) {} + + @override + void setDomStorageEnabled(int instanceId, bool flag) {} + + @override + void setJavaScriptCanOpenWindowsAutomatically(int instanceId, bool flag) {} + + @override + void setJavaScriptEnabled(int instanceId, bool flag) {} + + @override + void setLoadWithOverviewMode(int instanceId, bool overview) {} + + @override + void setMediaPlaybackRequiresUserGesture(int instanceId, bool require) {} + + @override + void setSupportMultipleWindows(int instanceId, bool support) {} + + @override + void setSupportZoom(int instanceId, bool support) {} + + @override + void setUseWideViewPort(int instanceId, bool use) {} + + @override + void setUserAgentString(int instanceId, String userAgentString) {} +} + +class TestJavaScriptChannelHostApiImpl extends TestJavaScriptChannelHostApi { + @override + void create(int instanceId, String channelName) {} + + @override + void dispose(int instanceId) {} +} + +class TestWebViewClientHostApiImpl extends TestWebViewClientHostApi { + @override + void create(int instanceId, bool shouldOverrideUrlLoading) {} + + @override + void dispose(int instanceId) {} +} + +class TestDownloadListenerHostApiImpl extends TestDownloadListenerHostApi { + @override + void create(int instanceId) {} + + @override + void dispose(int instanceId) {} +} + +class TestWebChromeClientHostApiImpl extends TestWebChromeClientHostApi { + @override + void create(int instanceId, int webViewClientInstanceId) {} + + @override + void dispose(int instanceId) {} +} diff --git a/packages/webview_flutter/webview_flutter_android/test/instance_manager_test.dart b/packages/webview_flutter/webview_flutter_android/test/instance_manager_test.dart new file mode 100644 index 000000000000..fd020fc362c8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_android/test/instance_manager_test.dart @@ -0,0 +1,35 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_android/src/instance_manager.dart'; + +void main() { + group('$InstanceManager', () { + late InstanceManager testInstanceManager; + + setUp(() { + testInstanceManager = InstanceManager(); + }); + + test('tryAddInstance', () { + final Object object = Object(); + + expect(testInstanceManager.tryAddInstance(object), 0); + expect(testInstanceManager.getInstanceId(object), 0); + expect(testInstanceManager.getInstance(0), object); + expect(testInstanceManager.tryAddInstance(object), null); + }); + + test('removeInstance', () { + final Object object = Object(); + testInstanceManager.tryAddInstance(object); + + expect(testInstanceManager.removeInstance(object), 0); + expect(testInstanceManager.getInstanceId(object), null); + expect(testInstanceManager.getInstance(0), null); + expect(testInstanceManager.removeInstance(object), null); + }); + }); +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/AUTHORS b/packages/webview_flutter/webview_flutter_platform_interface/AUTHORS new file mode 100644 index 000000000000..78f9e5ad9f6b --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/AUTHORS @@ -0,0 +1,67 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom diff --git a/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md new file mode 100644 index 000000000000..4c7434a86b41 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/CHANGELOG.md @@ -0,0 +1,19 @@ +## 1.4.0 + +* Added `loadFile` and `loadHtml` interface methods. + +## 1.3.0 + +* Added `loadRequest` method to platform interface. + +## 1.2.0 + +* Added `runJavascript` and `runJavascriptReturningResult` interface methods to supersede `evaluateJavascript`. + +## 1.1.0 + +* Add `zoomEnabled` functionality to `WebSettings`. + +## 1.0.0 + +* Extracted platform interface from `webview_flutter`. \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter_platform_interface/LICENSE b/packages/webview_flutter/webview_flutter_platform_interface/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/webview_flutter/webview_flutter_platform_interface/README.md b/packages/webview_flutter/webview_flutter_platform_interface/README.md new file mode 100644 index 000000000000..31e57ab61597 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/README.md @@ -0,0 +1,23 @@ +# webview_flutter_platform_interface + +A common platform interface for the [`webview_flutter`](https://pub.dev/packages/webview_flutter) plugin. + +This interface allows platform-specific implementations of the `webview_flutter` +plugin, as well as the plugin itself, to ensure they are supporting the +same interface. + +# Usage + +To implement a new platform-specific implementation of `webview_flutter`, extend +[`WebviewPlatform`](lib/src/platform_interface/webview_platform.dart) with an implementation that performs the +platform-specific behavior, and when you register your plugin, set the default +`WebviewPlatform` by calling +`WebviewPlatform.setInstance(MyPlatformWebview())`. + +# Note on breaking changes + +Strongly prefer non-breaking changes (such as adding a method to the interface) +over breaking changes for this package. + +See https://flutter.dev/go/platform-interface-breaking-changes for a discussion +on why a less-clean interface is preferable to a breaking change. diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart new file mode 100644 index 000000000000..b87fb4cd138c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/method_channel/webview_method_channel.dart @@ -0,0 +1,270 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../platform_interface/javascript_channel_registry.dart'; +import '../platform_interface/platform_interface.dart'; +import '../types/types.dart'; + +/// A [WebViewPlatformController] that uses a method channel to control the webview. +class MethodChannelWebViewPlatform implements WebViewPlatformController { + /// Constructs an instance that will listen for webviews broadcasting to the + /// given [id], using the given [WebViewPlatformCallbacksHandler]. + MethodChannelWebViewPlatform( + int id, + this._platformCallbacksHandler, + this._javascriptChannelRegistry, + ) : assert(_platformCallbacksHandler != null), + _channel = MethodChannel('plugins.flutter.io/webview_$id') { + _channel.setMethodCallHandler(_onMethodCall); + } + + final JavascriptChannelRegistry _javascriptChannelRegistry; + + final WebViewPlatformCallbacksHandler _platformCallbacksHandler; + + final MethodChannel _channel; + + static const MethodChannel _cookieManagerChannel = + MethodChannel('plugins.flutter.io/cookie_manager'); + + Future _onMethodCall(MethodCall call) async { + switch (call.method) { + case 'javascriptChannelMessage': + final String channel = call.arguments['channel']!; + final String message = call.arguments['message']!; + _javascriptChannelRegistry.onJavascriptChannelMessage(channel, message); + return true; + case 'navigationRequest': + return await _platformCallbacksHandler.onNavigationRequest( + url: call.arguments['url']!, + isForMainFrame: call.arguments['isForMainFrame']!, + ); + case 'onPageFinished': + _platformCallbacksHandler.onPageFinished(call.arguments['url']!); + return null; + case 'onProgress': + _platformCallbacksHandler.onProgress(call.arguments['progress']); + return null; + case 'onPageStarted': + _platformCallbacksHandler.onPageStarted(call.arguments['url']!); + return null; + case 'onWebResourceError': + _platformCallbacksHandler.onWebResourceError( + WebResourceError( + errorCode: call.arguments['errorCode']!, + description: call.arguments['description']!, + // iOS doesn't support `failingUrl`. + failingUrl: call.arguments['failingUrl'], + domain: call.arguments['domain'], + errorType: call.arguments['errorType'] == null + ? null + : WebResourceErrorType.values.firstWhere( + (WebResourceErrorType type) { + return type.toString() == + '$WebResourceErrorType.${call.arguments['errorType']}'; + }, + ), + ), + ); + return null; + } + + throw MissingPluginException( + '${call.method} was invoked but has no handler', + ); + } + + @override + Future loadFile(String absoluteFilePath) async { + assert(absoluteFilePath != null); + return _channel.invokeMethod('loadFile', absoluteFilePath); + } + + @override + Future loadHtmlString( + String html, { + String? baseUrl, + }) async { + assert(html != null); + return _channel.invokeMethod('loadHtmlString', { + 'html': html, + 'baseUrl': baseUrl, + }); + } + + @override + Future loadUrl( + String url, + Map? headers, + ) async { + assert(url != null); + return _channel.invokeMethod('loadUrl', { + 'url': url, + 'headers': headers, + }); + } + + @override + Future loadAssetHtmlFile(String url) => + _channel.invokeMethod('loadAssetHtmlFile', url); + + @override + Future loadLocalHtmlFile(String url) => + _channel.invokeMethod('loadLocalHtmlFile', url); + + @override + Future loadRequest(WebViewRequest request) async { + assert(request != null); + return _channel.invokeMethod('loadRequest', { + 'request': request.toJson(), + }); + } + + @override + Future currentUrl() => _channel.invokeMethod('currentUrl'); + + @override + Future canGoBack() => + _channel.invokeMethod("canGoBack").then((result) => result!); + + @override + Future canGoForward() => + _channel.invokeMethod("canGoForward").then((result) => result!); + + @override + Future goBack() => _channel.invokeMethod("goBack"); + + @override + Future goForward() => _channel.invokeMethod("goForward"); + + @override + Future reload() => _channel.invokeMethod("reload"); + + @override + Future clearCache() => _channel.invokeMethod("clearCache"); + + @override + Future updateSettings(WebSettings settings) async { + final Map updatesMap = _webSettingsToMap(settings); + if (updatesMap.isNotEmpty) { + await _channel.invokeMethod('updateSettings', updatesMap); + } + } + + @override + Future evaluateJavascript(String javascript) { + return _channel + .invokeMethod('evaluateJavascript', javascript) + .then((result) => result!); + } + + @override + Future runJavascript(String javascript) async { + await _channel.invokeMethod('runJavascript', javascript); + } + + @override + Future runJavascriptReturningResult(String javascript) { + return _channel + .invokeMethod('runJavascriptReturningResult', javascript) + .then((result) => result!); + } + + @override + Future addJavascriptChannels(Set javascriptChannelNames) { + return _channel.invokeMethod( + 'addJavascriptChannels', javascriptChannelNames.toList()); + } + + @override + Future removeJavascriptChannels(Set javascriptChannelNames) { + return _channel.invokeMethod( + 'removeJavascriptChannels', javascriptChannelNames.toList()); + } + + @override + Future getTitle() => _channel.invokeMethod("getTitle"); + + @override + Future scrollTo(int x, int y) { + return _channel.invokeMethod('scrollTo', { + 'x': x, + 'y': y, + }); + } + + @override + Future scrollBy(int x, int y) { + return _channel.invokeMethod('scrollBy', { + 'x': x, + 'y': y, + }); + } + + @override + Future getScrollX() => + _channel.invokeMethod("getScrollX").then((result) => result!); + + @override + Future getScrollY() => + _channel.invokeMethod("getScrollY").then((result) => result!); + + /// Method channel implementation for [WebViewPlatform.clearCookies]. + static Future clearCookies() { + return _cookieManagerChannel + .invokeMethod('clearCookies') + .then((dynamic result) => result!); + } + + static Map _webSettingsToMap(WebSettings? settings) { + final Map map = {}; + void _addIfNonNull(String key, dynamic value) { + if (value == null) { + return; + } + map[key] = value; + } + + void _addSettingIfPresent(String key, WebSetting setting) { + if (!setting.isPresent) { + return; + } + map[key] = setting.value; + } + + _addIfNonNull('jsMode', settings!.javascriptMode?.index); + _addIfNonNull('hasNavigationDelegate', settings.hasNavigationDelegate); + _addIfNonNull('hasProgressTracking', settings.hasProgressTracking); + _addIfNonNull('debuggingEnabled', settings.debuggingEnabled); + _addIfNonNull( + 'gestureNavigationEnabled', settings.gestureNavigationEnabled); + _addIfNonNull( + 'allowsInlineMediaPlayback', settings.allowsInlineMediaPlayback); + _addSettingIfPresent('userAgent', settings.userAgent); + _addIfNonNull('zoomEnabled', settings.zoomEnabled); + return map; + } + + /// Converts a [CreationParams] object to a map as expected by `platform_views` channel. + /// + /// This is used for the `creationParams` argument of the platform views created by + /// [AndroidWebViewBuilder] and [CupertinoWebViewBuilder]. + static Map creationParamsToMap( + CreationParams creationParams, { + bool usesHybridComposition = false, + }) { + return { + 'initialUrl': creationParams.initialUrl, + 'settings': _webSettingsToMap(creationParams.webSettings), + 'javascriptChannelNames': creationParams.javascriptChannelNames.toList(), + 'userAgent': creationParams.userAgent, + 'autoMediaPlaybackPolicy': creationParams.autoMediaPlaybackPolicy.index, + 'usesHybridComposition': usesHybridComposition, + }; + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/javascript_channel_registry.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/javascript_channel_registry.dart new file mode 100644 index 000000000000..142d8eb00950 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/javascript_channel_registry.dart @@ -0,0 +1,42 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../types/javascript_channel.dart'; +import '../types/javascript_message.dart'; + +/// Utility class for managing named JavaScript channels and forwarding incoming +/// messages on the correct channel. +class JavascriptChannelRegistry { + /// Constructs a [JavascriptChannelRegistry] initializing it with the given + /// set of [JavascriptChannel]s. + JavascriptChannelRegistry(Set? channels) { + updateJavascriptChannelsFromSet(channels); + } + + /// Maps a channel name to a channel. + final Map channels = {}; + + /// Invoked when a JavaScript channel message is received. + void onJavascriptChannelMessage(String channel, String message) { + final JavascriptChannel? javascriptChannel = channels[channel]; + + if (javascriptChannel == null) { + throw ArgumentError('No channel registered with name $channel.'); + } + + javascriptChannel.onMessageReceived(JavascriptMessage(message)); + } + + /// Updates the set of [JavascriptChannel]s with the new set. + void updateJavascriptChannelsFromSet(Set? channels) { + this.channels.clear(); + if (channels == null) { + return; + } + + for (final JavascriptChannel channel in channels) { + this.channels[channel.name] = channel; + } + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/platform_interface.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/platform_interface.dart new file mode 100644 index 000000000000..43f967fb13b0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/platform_interface.dart @@ -0,0 +1,8 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'javascript_channel_registry.dart'; +export 'webview_platform.dart'; +export 'webview_platform_callbacks_handler.dart'; +export 'webview_platform_controller.dart'; diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform.dart new file mode 100644 index 000000000000..4732f54d6456 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform.dart @@ -0,0 +1,66 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/src/platform_interface/javascript_channel_registry.dart'; + +import '../types/types.dart'; +import 'webview_platform_callbacks_handler.dart'; +import 'webview_platform_controller.dart'; + +/// Signature for callbacks reporting that a [WebViewPlatformController] was created. +/// +/// See also the `onWebViewPlatformCreated` argument for [WebViewPlatform.build]. +typedef WebViewPlatformCreatedCallback = void Function( + WebViewPlatformController? webViewPlatformController); + +/// Interface for a platform implementation of a WebView. +/// +/// [WebView.platform] controls the builder that is used by [WebView]. +/// [AndroidWebViewPlatform] and [CupertinoWebViewPlatform] are the default implementations +/// for Android and iOS respectively. +abstract class WebViewPlatform { + /// Builds a new WebView. + /// + /// Returns a Widget tree that embeds the created webview. + /// + /// `creationParams` are the initial parameters used to setup the webview. + /// + /// `webViewPlatformHandler` will be used for handling callbacks that are made by the created + /// [WebViewPlatformController]. + /// + /// `onWebViewPlatformCreated` will be invoked after the platform specific [WebViewPlatformController] + /// implementation is created with the [WebViewPlatformController] instance as a parameter. + /// + /// `gestureRecognizers` specifies which gestures should be consumed by the web view. + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// When `gestureRecognizers` is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + /// + /// `webViewPlatformHandler` must not be null. + Widget build({ + required BuildContext context, + // TODO(amirh): convert this to be the actual parameters. + // I'm starting without it as the PR is starting to become pretty big. + // I'll followup with the conversion PR. + required CreationParams creationParams, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + }); + + /// Clears all cookies for all [WebView] instances. + /// + /// Returns true if cookies were present before clearing, else false. + Future clearCookies() { + throw UnimplementedError( + "WebView clearCookies is not implemented on the current platform"); + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_callbacks_handler.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_callbacks_handler.dart new file mode 100644 index 000000000000..44dae2ece434 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_callbacks_handler.dart @@ -0,0 +1,32 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import '../types/types.dart'; + +/// Interface for callbacks made by [WebViewPlatformController]. +/// +/// The webview plugin implements this class, and passes an instance to the [WebViewPlatformController]. +/// [WebViewPlatformController] is notifying this handler on events that happened on the platform's webview. +abstract class WebViewPlatformCallbacksHandler { + /// Invoked by [WebViewPlatformController] when a navigation request is pending. + /// + /// If true is returned the navigation is allowed, otherwise it is blocked. + FutureOr onNavigationRequest( + {required String url, required bool isForMainFrame}); + + /// Invoked by [WebViewPlatformController] when a page has started loading. + void onPageStarted(String url); + + /// Invoked by [WebViewPlatformController] when a page has finished loading. + void onPageFinished(String url); + + /// Invoked by [WebViewPlatformController] when a page is loading. + /// /// Only works when [WebSettings.hasProgressTracking] is set to `true`. + void onProgress(int progress); + + /// Report web resource loading error to the host application. + void onWebResourceError(WebResourceError error); +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart new file mode 100644 index 000000000000..cfc817450372 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/platform_interface/webview_platform_controller.dart @@ -0,0 +1,240 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../types/types.dart'; +import 'webview_platform_callbacks_handler.dart'; + +/// Interface for talking to the webview's platform implementation. +/// +/// An instance implementing this interface is passed to the `onWebViewPlatformCreated` callback that is +/// passed to [WebViewPlatformBuilder#onWebViewPlatformCreated]. +/// +/// Platform implementations that live in a separate package should extend this class rather than +/// implement it as webview_flutter does not consider newly added methods to be breaking changes. +/// Extending this class (using `extends`) ensures that the subclass will get the default +/// implementation, while platform implementations that `implements` this interface will be broken +/// by newly added [WebViewPlatformController] methods. +abstract class WebViewPlatformController { + /// Creates a new WebViewPlatform. + /// + /// Callbacks made by the WebView will be delegated to `handler`. + /// + /// The `handler` parameter must not be null. + WebViewPlatformController(WebViewPlatformCallbacksHandler handler); + + /// Loads the file located on the specified [absoluteFilePath]. + /// + /// The [absoluteFilePath] parameter should contain the absolute path to the + /// file as it is stored on the device. For example: + /// `/Users/username/Documents/www/index.html`. + /// + /// Throws an ArgumentError if the [absoluteFilePath] does not exist. + Future loadFile( + String absoluteFilePath, + ) { + throw UnimplementedError( + "WebView loadFlutterAsset is not implemented on the current platform"); + } + + /// Loads the supplied HTML string. + /// + /// The [baseUrl] parameter is used when resolving relative URLs within the + /// HTML string. + Future loadHtmlString( + String html, { + String? baseUrl, + }) { + throw UnimplementedError( + "WebView loadHtmlString is not implemented on the current platform"); + } + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, + Map? headers, + ) { + throw UnimplementedError( + "WebView loadUrl is not implemented on the current platform"); + } + + /// Makes a specific HTTP request ands loads the response in the webview. + /// + /// [WebViewRequest.method] must be one of the supported HTTP methods + /// in [WebViewRequestMethod]. + /// + /// If [WebViewRequest.headers] is not empty, its key-value pairs will be + /// added as the headers for the request. + /// + /// If [WebViewRequest.body] is not null, it will be added as the body + /// for the request. + /// + /// Throws an ArgumentError if [WebViewRequest.uri] has empty scheme. + Future loadRequest( + WebViewRequest request, + ) { + throw UnimplementedError( + "WebView loadRequest is not implemented on the current platform"); + } + + /// Updates the webview settings. + /// + /// Any non null field in `settings` will be set as the new setting value. + /// All null fields in `settings` are ignored. + Future updateSettings(WebSettings setting) { + throw UnimplementedError( + "WebView updateSettings is not implemented on the current platform"); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If no URL was ever loaded, returns `null`. + Future currentUrl() { + throw UnimplementedError( + "WebView currentUrl is not implemented on the current platform"); + } + + /// Checks whether there's a back history item. + Future canGoBack() { + throw UnimplementedError( + "WebView canGoBack is not implemented on the current platform"); + } + + /// Checks whether there's a forward history item. + Future canGoForward() { + throw UnimplementedError( + "WebView canGoForward is not implemented on the current platform"); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + throw UnimplementedError( + "WebView goBack is not implemented on the current platform"); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + throw UnimplementedError( + "WebView goForward is not implemented on the current platform"); + } + + /// Reloads the current URL. + Future reload() { + throw UnimplementedError( + "WebView reload is not implemented on the current platform"); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + Future clearCache() { + throw UnimplementedError( + "WebView clearCache is not implemented on the current platform"); + } + + /// Evaluates a JavaScript expression in the context of the current page. + /// + /// The Future completes with an error if a JavaScript error occurred, or if the type of the + /// evaluated expression is not supported (e.g on iOS not all non-primitive types can be evaluated). + Future evaluateJavascript(String javascript) { + throw UnimplementedError( + "WebView evaluateJavascript is not implemented on the current platform"); + } + + /// Runs the given JavaScript in the context of the current page. + /// + /// The Future completes with an error if a JavaScript error occurred. + Future runJavascript(String javascript) { + throw UnimplementedError( + "WebView runJavascript is not implemented on the current platform"); + } + + /// Runs the given JavaScript in the context of the current page, and returns the result. + /// + /// The Future completes with an error if a JavaScript error occurred, or if the + /// type the given expression evaluates to is unsupported. Unsupported values include + /// certain non-primitive types on iOS, as well as `undefined` or `null` on iOS 14+. + Future runJavascriptReturningResult(String javascript) { + throw UnimplementedError( + "WebView runJavascriptReturningResult is not implemented on the current platform"); + } + + /// Adds new JavaScript channels to the set of enabled channels. + /// + /// For each value in this list the platform's webview should make sure that a corresponding + /// property with a postMessage method is set on `window`. For example for a JavaScript channel + /// named `Foo` it should be possible for JavaScript code executing in the webview to do + /// + /// ```javascript + /// Foo.postMessage('hello'); + /// ``` + /// + /// See also: [CreationParams.javascriptChannelNames]. + Future addJavascriptChannels(Set javascriptChannelNames) { + throw UnimplementedError( + "WebView addJavascriptChannels is not implemented on the current platform"); + } + + /// Removes JavaScript channel names from the set of enabled channels. + /// + /// This disables channels that were previously enabled by [addJavascriptChannels] or through + /// [CreationParams.javascriptChannelNames]. + Future removeJavascriptChannels(Set javascriptChannelNames) { + throw UnimplementedError( + "WebView removeJavascriptChannels is not implemented on the current platform"); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + throw UnimplementedError( + "WebView getTitle is not implemented on the current platform"); + } + + /// Set the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the position to scroll to in WebView pixels. + Future scrollTo(int x, int y) { + throw UnimplementedError( + "WebView scrollTo is not implemented on the current platform"); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by. + Future scrollBy(int x, int y) { + throw UnimplementedError( + "WebView scrollBy is not implemented on the current platform"); + } + + /// Return the horizontal scroll position of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + throw UnimplementedError( + "WebView getScrollX is not implemented on the current platform"); + } + + /// Return the vertical scroll position of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + throw UnimplementedError( + "WebView getScrollY is not implemented on the current platform"); + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/auto_media_playback_policy.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/auto_media_playback_policy.dart new file mode 100644 index 000000000000..7d6927ac7957 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/auto_media_playback_policy.dart @@ -0,0 +1,22 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Specifies possible restrictions on automatic media playback. +/// +/// This is typically used in [WebView.initialMediaPlaybackPolicy]. +// The method channel implementation is marshalling this enum to the value's index, so the order +// is important. +enum AutoMediaPlaybackPolicy { + /// Starting any kind of media playback requires a user action. + /// + /// For example: JavaScript code cannot start playing media unless the code was executed + /// as a result of a user action (like a touch event). + require_user_action_for_all_media_types, + + /// Starting any kind of media playback is always allowed. + /// + /// For example: JavaScript code that's triggered when the page is loaded can start playing + /// video or audio. + always_allow, +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/creation_params.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/creation_params.dart new file mode 100644 index 000000000000..f213e976ad84 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/creation_params.dart @@ -0,0 +1,60 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'auto_media_playback_policy.dart'; +import 'web_settings.dart'; + +/// Configuration to use when creating a new [WebViewPlatformController]. +/// +/// The `autoMediaPlaybackPolicy` parameter must not be null. +class CreationParams { + /// Constructs an instance to use when creating a new + /// [WebViewPlatformController]. + /// + /// The `autoMediaPlaybackPolicy` parameter must not be null. + CreationParams({ + this.initialUrl, + this.webSettings, + this.javascriptChannelNames = const {}, + this.userAgent, + this.autoMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + }) : assert(autoMediaPlaybackPolicy != null); + + /// The initialUrl to load in the webview. + /// + /// When null the webview will be created without loading any page. + final String? initialUrl; + + /// The initial [WebSettings] for the new webview. + /// + /// This can later be updated with [WebViewPlatformController.updateSettings]. + final WebSettings? webSettings; + + /// The initial set of JavaScript channels that are configured for this webview. + /// + /// For each value in this set the platform's webview should make sure that a corresponding + /// property with a postMessage method is set on `window`. For example for a JavaScript channel + /// named `Foo` it should be possible for JavaScript code executing in the webview to do + /// + /// ```javascript + /// Foo.postMessage('hello'); + /// ``` + // TODO(amirh): describe what should happen when postMessage is called once that code is migrated + // to PlatformWebView. + final Set javascriptChannelNames; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + final String? userAgent; + + /// Which restrictions apply on automatic media playback. + final AutoMediaPlaybackPolicy autoMediaPlaybackPolicy; + + @override + String toString() { + return '$runtimeType(initialUrl: $initialUrl, settings: $webSettings, javascriptChannelNames: $javascriptChannelNames, UserAgent: $userAgent)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart new file mode 100644 index 000000000000..f32a41893eb5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_channel.dart @@ -0,0 +1,39 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'javascript_message.dart'; + +/// Callback type for handling messages sent from JavaScript running in a web view. +typedef void JavascriptMessageHandler(JavascriptMessage message); + +final RegExp _validChannelNames = RegExp('^[a-zA-Z_][a-zA-Z0-9_]*\$'); + +/// A named channel for receiving messaged from JavaScript code running inside a web view. +class JavascriptChannel { + /// Constructs a JavaScript channel. + /// + /// The parameters `name` and `onMessageReceived` must not be null. + JavascriptChannel({ + required this.name, + required this.onMessageReceived, + }) : assert(name != null), + assert(onMessageReceived != null), + assert(_validChannelNames.hasMatch(name)); + + /// The channel's name. + /// + /// Passing this channel object as part of a [WebView.javascriptChannels] adds a channel object to + /// the JavaScript window object's property named `name`. + /// + /// The name must start with a letter or underscore(_), followed by any combination of those + /// characters plus digits. + /// + /// Note that any JavaScript existing `window` property with this name will be overriden. + /// + /// See also [WebView.javascriptChannels] for more details on the channel registration mechanism. + final String name; + + /// A callback that's invoked when a message is received through the channel. + final JavascriptMessageHandler onMessageReceived; +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_message.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_message.dart new file mode 100644 index 000000000000..8d080452c54a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_message.dart @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A message that was sent by JavaScript code running in a [WebView]. +class JavascriptMessage { + /// Constructs a JavaScript message object. + /// + /// The `message` parameter must not be null. + const JavascriptMessage(this.message) : assert(message != null); + + /// The contents of the message that was sent by the JavaScript code. + final String message; +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_mode.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_mode.dart new file mode 100644 index 000000000000..53d049175907 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/javascript_mode.dart @@ -0,0 +1,12 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Describes the state of JavaScript support in a given web view. +enum JavascriptMode { + /// JavaScript execution is disabled. + disabled, + + /// JavaScript execution is not restricted. + unrestricted, +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/types.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/types.dart new file mode 100644 index 000000000000..8ce834196cd8 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/types.dart @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'auto_media_playback_policy.dart'; +export 'creation_params.dart'; +export 'javascript_channel.dart'; +export 'javascript_message.dart'; +export 'javascript_mode.dart'; +export 'web_resource_error.dart'; +export 'web_resource_error_type.dart'; +export 'web_settings.dart'; +export 'webview_request.dart'; diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error.dart new file mode 100644 index 000000000000..b61671f0ac45 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error.dart @@ -0,0 +1,57 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'web_resource_error_type.dart'; + +/// Error returned in `WebView.onWebResourceError` when a web resource loading error has occurred. +class WebResourceError { + /// Creates a new [WebResourceError] + /// + /// A user should not need to instantiate this class, but will receive one in + /// [WebResourceErrorCallback]. + WebResourceError({ + required this.errorCode, + required this.description, + this.domain, + this.errorType, + this.failingUrl, + }) : assert(errorCode != null), + assert(description != null); + + /// Raw code of the error from the respective platform. + /// + /// On Android, the error code will be a constant from a + /// [WebViewClient](https://developer.android.com/reference/android/webkit/WebViewClient#summary) and + /// will have a corresponding [errorType]. + /// + /// On iOS, the error code will be a constant from `NSError.code` in + /// Objective-C. See + /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html + /// for more information on error handling on iOS. Some possible error codes + /// can be found at https://developer.apple.com/documentation/webkit/wkerrorcode?language=objc. + final int errorCode; + + /// The domain of where to find the error code. + /// + /// This field is only available on iOS and represents a "domain" from where + /// the [errorCode] is from. This value is taken directly from an `NSError` + /// in Objective-C. See + /// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ErrorHandlingCocoa/ErrorObjectsDomains/ErrorObjectsDomains.html + /// for more information on error handling on iOS. + final String? domain; + + /// Description of the error that can be used to communicate the problem to the user. + final String description; + + /// The type this error can be categorized as. + /// + /// This will never be `null` on Android, but can be `null` on iOS. + final WebResourceErrorType? errorType; + + /// Gets the URL for which the resource request was made. + /// + /// This value is not provided on iOS. Alternatively, you can keep track of + /// the last values provided to [WebViewPlatformController.loadUrl]. + final String? failingUrl; +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error_type.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error_type.dart new file mode 100644 index 000000000000..a45816df8323 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_resource_error_type.dart @@ -0,0 +1,66 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Possible error type categorizations used by [WebResourceError]. +enum WebResourceErrorType { + /// User authentication failed on server. + authentication, + + /// Malformed URL. + badUrl, + + /// Failed to connect to the server. + connect, + + /// Failed to perform SSL handshake. + failedSslHandshake, + + /// Generic file error. + file, + + /// File not found. + fileNotFound, + + /// Server or proxy hostname lookup failed. + hostLookup, + + /// Failed to read or write to the server. + io, + + /// User authentication failed on proxy. + proxyAuthentication, + + /// Too many redirects. + redirectLoop, + + /// Connection timed out. + timeout, + + /// Too many requests during this load. + tooManyRequests, + + /// Generic error. + unknown, + + /// Resource load was canceled by Safe Browsing. + unsafeResource, + + /// Unsupported authentication scheme (not basic or digest). + unsupportedAuthScheme, + + /// Unsupported URI scheme. + unsupportedScheme, + + /// The web content process was terminated. + webContentProcessTerminated, + + /// The web view was invalidated. + webViewInvalidated, + + /// A JavaScript exception occurred. + javaScriptExceptionOccurred, + + /// The result of JavaScript execution could not be returned. + javaScriptResultTypeIsUnsupported, +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart new file mode 100644 index 000000000000..3d94153c886e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/web_settings.dart @@ -0,0 +1,127 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +import 'javascript_mode.dart'; + +/// A single setting for configuring a WebViewPlatform which may be absent. +class WebSetting { + /// Constructs an absent setting instance. + /// + /// The [isPresent] field for the instance will be false. + /// + /// Accessing [value] for an absent instance will throw. + WebSetting.absent() + : _value = null, + isPresent = false; + + /// Constructs a setting of the given `value`. + /// + /// The [isPresent] field for the instance will be true. + WebSetting.of(T value) + : _value = value, + isPresent = true; + + final T? _value; + + /// The setting's value. + /// + /// Throws if [WebSetting.isPresent] is false. + T get value { + if (!isPresent) { + throw StateError('Cannot access a value of an absent WebSetting'); + } + assert(isPresent); + // The intention of this getter is to return T whether it is nullable or + // not whereas _value is of type T? since _value can be null even when + // T is not nullable (when isPresent == false). + // + // We promote _value to T using `as T` instead of `!` operator to handle + // the case when _value is legitimately null (and T is a nullable type). + // `!` operator would always throw if _value is null. + return _value as T; + } + + /// True when this web setting instance contains a value. + /// + /// When false the [WebSetting.value] getter throws. + final bool isPresent; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + final WebSetting typedOther = other as WebSetting; + return typedOther.isPresent == isPresent && typedOther._value == _value; + } + + @override + int get hashCode => hashValues(_value, isPresent); +} + +/// Settings for configuring a WebViewPlatform. +/// +/// Initial settings are passed as part of [CreationParams], settings updates are sent with +/// [WebViewPlatform#updateSettings]. +/// +/// The `userAgent` parameter must not be null. +class WebSettings { + /// Construct an instance with initial settings. Future setting changes can be + /// sent with [WebviewPlatform#updateSettings]. + /// + /// The `userAgent` parameter must not be null. + WebSettings({ + this.javascriptMode, + this.hasNavigationDelegate, + this.hasProgressTracking, + this.debuggingEnabled, + this.gestureNavigationEnabled, + this.allowsInlineMediaPlayback, + this.zoomEnabled, + required this.userAgent, + }) : assert(userAgent != null); + + /// The JavaScript execution mode to be used by the webview. + final JavascriptMode? javascriptMode; + + /// Whether the [WebView] has a [NavigationDelegate] set. + final bool? hasNavigationDelegate; + + /// Whether the [WebView] should track page loading progress. + /// See also: [WebViewPlatformCallbacksHandler.onProgress] to get the progress. + final bool? hasProgressTracking; + + /// Whether to enable the platform's webview content debugging tools. + /// + /// See also: [WebView.debuggingEnabled]. + final bool? debuggingEnabled; + + /// Whether to play HTML5 videos inline or use the native full-screen controller on iOS. + /// + /// This will have no effect on Android. + final bool? allowsInlineMediaPlayback; + + /// The value used for the HTTP `User-Agent:` request header. + /// + /// If [userAgent.value] is null the platform's default user agent should be used. + /// + /// An absent value ([userAgent.isPresent] is false) represents no change to this setting from the + /// last time it was set. + /// + /// See also [WebView.userAgent]. + final WebSetting userAgent; + + /// Sets whether the WebView should support zooming using its on-screen zoom controls and gestures. + final bool? zoomEnabled; + + /// Whether to allow swipe based navigation in iOS. + /// + /// See also: [WebView.gestureNavigationEnabled] + final bool? gestureNavigationEnabled; + + @override + String toString() { + return 'WebSettings(javascriptMode: $javascriptMode, hasNavigationDelegate: $hasNavigationDelegate, hasProgressTracking: $hasProgressTracking, debuggingEnabled: $debuggingEnabled, gestureNavigationEnabled: $gestureNavigationEnabled, userAgent: $userAgent, allowsInlineMediaPlayback: $allowsInlineMediaPlayback)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/webview_request.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/webview_request.dart new file mode 100644 index 000000000000..5e520f1baa9e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/src/types/webview_request.dart @@ -0,0 +1,58 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +/// Defines the supported HTTP methods for loading a page in [WebView]. +enum WebViewRequestMethod { + /// HTTP GET method. + get, + + /// HTTP POST method. + post, +} + +/// Extension methods on the [WebViewRequestMethod] enum. +extension WebViewRequestMethodExtensions on WebViewRequestMethod { + /// Converts [WebViewRequestMethod] to [String] format. + String serialize() { + switch (this) { + case WebViewRequestMethod.get: + return 'get'; + case WebViewRequestMethod.post: + return 'post'; + } + } +} + +/// Defines the parameters that can be used to load a page in the [WebView]. +class WebViewRequest { + /// Creates the [WebViewRequest]. + WebViewRequest({ + required this.uri, + required this.method, + this.headers = const {}, + this.body, + }); + + /// URI for the request. + final Uri uri; + + /// HTTP method used to make the request. + final WebViewRequestMethod method; + + /// Headers for the request. + final Map headers; + + /// HTTP body for the request. + final Uint8List? body; + + /// Serializes the [WebViewRequest] to JSON. + Map toJson() => { + 'uri': this.uri.toString(), + 'method': this.method.serialize(), + 'headers': this.headers, + 'body': this.body, + }; +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/lib/webview_flutter_platform_interface.dart b/packages/webview_flutter/webview_flutter_platform_interface/lib/webview_flutter_platform_interface.dart new file mode 100644 index 000000000000..b508989ed978 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/lib/webview_flutter_platform_interface.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/platform_interface/platform_interface.dart'; +export 'src/types/types.dart'; +export 'src/method_channel/webview_method_channel.dart'; diff --git a/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml new file mode 100644 index 000000000000..4a4746d8ab68 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/pubspec.yaml @@ -0,0 +1,22 @@ +name: webview_flutter_platform_interface +description: A common platform interface for the webview_flutter plugin. +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_platform_interface +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview_flutter%22 +# NOTE: We strongly prefer non-breaking changes, even at the expense of a +# less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes +version: 1.4.0 + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^5.0.0 + pedantic: ^1.10.0 \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart new file mode 100644 index 000000000000..396013535aa9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/method_channel/webview_method_channel_test.dart @@ -0,0 +1,600 @@ +// Copyright 2013 The Flutter 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:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:webview_flutter_platform_interface/src/method_channel/webview_method_channel.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Tests on `plugin.flutter.io/webview_` channel', () { + const int channelId = 1; + const MethodChannel channel = + MethodChannel('plugins.flutter.io/webview_$channelId'); + final WebViewPlatformCallbacksHandler callbacksHandler = + MockWebViewPlatformCallbacksHandler(); + final JavascriptChannelRegistry javascriptChannelRegistry = + MockJavascriptChannelRegistry(); + + final List log = []; + channel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + + switch (methodCall.method) { + case 'currentUrl': + return 'https://test.url'; + case 'canGoBack': + case 'canGoForward': + return true; + case 'runJavascriptReturningResult': + case 'evaluateJavascript': + return methodCall.arguments as String; + case 'getScrollX': + return 10; + case 'getScrollY': + return 20; + } + + // Return null explicitly instead of relying on the implicit null + // returned by the method channel if no return statement is specified. + return null; + }); + + final MethodChannelWebViewPlatform webViewPlatform = + MethodChannelWebViewPlatform( + channelId, + callbacksHandler, + javascriptChannelRegistry, + ); + + tearDown(() { + log.clear(); + }); + + test('loadFile', () async { + await webViewPlatform.loadFile( + '/folder/asset.html', + ); + + expect( + log, + [ + isMethodCall( + 'loadFile', + arguments: '/folder/asset.html', + ), + ], + ); + }); + + test('loadHtmlString without base URL', () async { + await webViewPlatform.loadHtmlString( + 'Test HTML string', + ); + + expect( + log, + [ + isMethodCall( + 'loadHtmlString', + arguments: { + 'html': 'Test HTML string', + 'baseUrl': null, + }, + ), + ], + ); + }); + + test('loadHtmlString without base URL', () async { + await webViewPlatform.loadHtmlString( + 'Test HTML string', + baseUrl: 'https://flutter.dev', + ); + + expect( + log, + [ + isMethodCall( + 'loadHtmlString', + arguments: { + 'html': 'Test HTML string', + 'baseUrl': 'https://flutter.dev', + }, + ), + ], + ); + }); + + test('loadUrl with headers', () async { + await webViewPlatform.loadUrl( + 'https://test.url', + const { + 'Content-Type': 'text/plain', + 'Accept': 'text/html', + }, + ); + + expect( + log, + [ + isMethodCall( + 'loadUrl', + arguments: { + 'url': 'https://test.url', + 'headers': { + 'Content-Type': 'text/plain', + 'Accept': 'text/html', + }, + }, + ), + ], + ); + }); + + test('loadUrl without headers', () async { + await webViewPlatform.loadUrl( + 'https://test.url', + null, + ); + + expect( + log, + [ + isMethodCall( + 'loadUrl', + arguments: { + 'url': 'https://test.url', + 'headers': null, + }, + ), + ], + ); + }); + + test('loadRequest', () async { + await webViewPlatform.loadRequest(WebViewRequest( + uri: Uri.parse('https://test.url'), + method: WebViewRequestMethod.get, + )); + + expect( + log, + [ + isMethodCall( + 'loadRequest', + arguments: { + 'request': { + 'uri': 'https://test.url', + 'method': 'get', + 'headers': {}, + 'body': null, + } + }, + ), + ], + ); + }); + + test('loadRequest with optional parameters', () async { + await webViewPlatform.loadRequest(WebViewRequest( + uri: Uri.parse('https://test.url'), + method: WebViewRequestMethod.get, + headers: {'foo': 'bar'}, + body: Uint8List.fromList('hello world'.codeUnits), + )); + + expect( + log, + [ + isMethodCall( + 'loadRequest', + arguments: { + 'request': { + 'uri': 'https://test.url', + 'method': 'get', + 'headers': {'foo': 'bar'}, + 'body': 'hello world'.codeUnits, + } + }, + ), + ], + ); + }); + + test('currentUrl', () async { + final String? currentUrl = await webViewPlatform.currentUrl(); + + expect(currentUrl, 'https://test.url'); + expect( + log, + [ + isMethodCall( + 'currentUrl', + arguments: null, + ), + ], + ); + }); + + test('canGoBack', () async { + final bool canGoBack = await webViewPlatform.canGoBack(); + + expect(canGoBack, true); + expect( + log, + [ + isMethodCall( + 'canGoBack', + arguments: null, + ), + ], + ); + }); + + test('canGoForward', () async { + final bool canGoForward = await webViewPlatform.canGoForward(); + + expect(canGoForward, true); + expect( + log, + [ + isMethodCall( + 'canGoForward', + arguments: null, + ), + ], + ); + }); + + test('goBack', () async { + await webViewPlatform.goBack(); + + expect( + log, + [ + isMethodCall( + 'goBack', + arguments: null, + ), + ], + ); + }); + + test('goForward', () async { + await webViewPlatform.goForward(); + + expect( + log, + [ + isMethodCall( + 'goForward', + arguments: null, + ), + ], + ); + }); + + test('reload', () async { + await webViewPlatform.reload(); + + expect( + log, + [ + isMethodCall( + 'reload', + arguments: null, + ), + ], + ); + }); + + test('clearCache', () async { + await webViewPlatform.clearCache(); + + expect( + log, + [ + isMethodCall( + 'clearCache', + arguments: null, + ), + ], + ); + }); + + test('updateSettings', () async { + final WebSettings settings = + WebSettings(userAgent: WebSetting.of('Dart Test')); + await webViewPlatform.updateSettings(settings); + + expect( + log, + [ + isMethodCall( + 'updateSettings', + arguments: { + 'userAgent': 'Dart Test', + }, + ), + ], + ); + }); + + test('updateSettings all parameters', () async { + final WebSettings settings = WebSettings( + userAgent: WebSetting.of('Dart Test'), + javascriptMode: JavascriptMode.disabled, + hasNavigationDelegate: true, + hasProgressTracking: true, + debuggingEnabled: true, + gestureNavigationEnabled: true, + allowsInlineMediaPlayback: true, + zoomEnabled: false, + ); + await webViewPlatform.updateSettings(settings); + + expect( + log, + [ + isMethodCall( + 'updateSettings', + arguments: { + 'userAgent': 'Dart Test', + 'jsMode': 0, + 'hasNavigationDelegate': true, + 'hasProgressTracking': true, + 'debuggingEnabled': true, + 'gestureNavigationEnabled': true, + 'allowsInlineMediaPlayback': true, + 'zoomEnabled': false, + }, + ), + ], + ); + }); + + test('updateSettings without settings', () async { + final WebSettings settings = + WebSettings(userAgent: WebSetting.absent()); + await webViewPlatform.updateSettings(settings); + + expect( + log.isEmpty, + true, + ); + }); + + test('evaluateJavascript', () async { + final String evaluateJavascript = + await webViewPlatform.evaluateJavascript( + 'This simulates some JavaScript code.', + ); + + expect('This simulates some JavaScript code.', evaluateJavascript); + expect( + log, + [ + isMethodCall( + 'evaluateJavascript', + arguments: 'This simulates some JavaScript code.', + ), + ], + ); + }); + + test('runJavascript', () async { + await webViewPlatform.runJavascript( + 'This simulates some JavaScript code.', + ); + + expect( + log, + [ + isMethodCall( + 'runJavascript', + arguments: 'This simulates some JavaScript code.', + ), + ], + ); + }); + + test('runJavascriptReturningResult', () async { + final String evaluateJavascript = + await webViewPlatform.runJavascriptReturningResult( + 'This simulates some JavaScript code.', + ); + + expect('This simulates some JavaScript code.', evaluateJavascript); + expect( + log, + [ + isMethodCall( + 'runJavascriptReturningResult', + arguments: 'This simulates some JavaScript code.', + ), + ], + ); + }); + + test('addJavascriptChannels', () async { + final Set channels = {'channel one', 'channel two'}; + await webViewPlatform.addJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'addJavascriptChannels', + arguments: [ + 'channel one', + 'channel two', + ], + ), + ]); + }); + + test('addJavascriptChannels without channels', () async { + final Set channels = {}; + await webViewPlatform.addJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'addJavascriptChannels', + arguments: [], + ), + ]); + }); + + test('removeJavascriptChannels', () async { + final Set channels = {'channel one', 'channel two'}; + await webViewPlatform.removeJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'removeJavascriptChannels', + arguments: [ + 'channel one', + 'channel two', + ], + ), + ]); + }); + + test('removeJavascriptChannels without channels', () async { + final Set channels = {}; + await webViewPlatform.removeJavascriptChannels(channels); + + expect(log, [ + isMethodCall( + 'removeJavascriptChannels', + arguments: [], + ), + ]); + }); + + test('getTitle', () async { + final String? title = await webViewPlatform.getTitle(); + + expect(title, null); + expect( + log, + [ + isMethodCall('getTitle', arguments: null), + ], + ); + }); + + test('scrollTo', () async { + await webViewPlatform.scrollTo(10, 20); + + expect( + log, + [ + isMethodCall( + 'scrollTo', + arguments: { + 'x': 10, + 'y': 20, + }, + ), + ], + ); + }); + + test('scrollBy', () async { + await webViewPlatform.scrollBy(10, 20); + + expect( + log, + [ + isMethodCall( + 'scrollBy', + arguments: { + 'x': 10, + 'y': 20, + }, + ), + ], + ); + }); + + test('getScrollX', () async { + final int x = await webViewPlatform.getScrollX(); + + expect(x, 10); + expect( + log, + [ + isMethodCall( + 'getScrollX', + arguments: null, + ), + ], + ); + }); + + test('getScrollY', () async { + final int y = await webViewPlatform.getScrollY(); + + expect(y, 20); + expect( + log, + [ + isMethodCall( + 'getScrollY', + arguments: null, + ), + ], + ); + }); + }); + + group('Tests on `plugins.flutter.io/cookie_manager` channel', () { + const MethodChannel cookieChannel = + MethodChannel('plugins.flutter.io/cookie_manager'); + + final List log = []; + cookieChannel.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + + if (methodCall.method == 'clearCookies') { + return true; + } + + // Return null explicitly instead of relying on the implicit null + // returned by the method channel if no return statement is specified. + return null; + }); + + tearDown(() { + log.clear(); + }); + + test('clearCookies', () async { + final bool clearCookies = + await MethodChannelWebViewPlatform.clearCookies(); + + expect(clearCookies, true); + expect( + log, + [ + isMethodCall( + 'clearCookies', + arguments: null, + ), + ], + ); + }); + }); +} + +class MockWebViewPlatformCallbacksHandler extends Mock + implements WebViewPlatformCallbacksHandler {} + +class MockJavascriptChannelRegistry extends Mock + implements JavascriptChannelRegistry {} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/platform_interface/javascript_channel_registry_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/platform_interface/javascript_channel_registry_test.dart new file mode 100644 index 000000000000..55d0e1e13bd1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/platform_interface/javascript_channel_registry_test.dart @@ -0,0 +1,119 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/src/types/javascript_channel.dart'; +import 'package:webview_flutter_platform_interface/src/types/types.dart'; +import 'package:webview_flutter_platform_interface/src/platform_interface/javascript_channel_registry.dart'; + +void main() { + final Map _log = {}; + final Set _channels = { + JavascriptChannel( + name: 'js_channel_1', + onMessageReceived: (JavascriptMessage message) => + _log['js_channel_1'] = message.message, + ), + JavascriptChannel( + name: 'js_channel_2', + onMessageReceived: (JavascriptMessage message) => + _log['js_channel_2'] = message.message, + ), + JavascriptChannel( + name: 'js_channel_3', + onMessageReceived: (JavascriptMessage message) => + _log['js_channel_3'] = message.message, + ), + }; + + tearDown(() { + _log.clear(); + }); + + test('ctor should initialize with channels.', () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect(registry.channels.length, 3); + for (final JavascriptChannel channel in _channels) { + expect(registry.channels[channel.name], channel); + } + }); + + test('onJavascriptChannelMessage should forward message on correct channel.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + registry.onJavascriptChannelMessage( + 'js_channel_2', + 'test message on channel 2', + ); + + expect( + _log, + containsPair( + 'js_channel_2', + 'test message on channel 2', + )); + }); + + test( + 'onJavascriptChannelMessage should throw ArgumentError when message arrives on non-existing channel.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect( + () => registry.onJavascriptChannelMessage( + 'js_channel_4', + 'test message on channel 2', + ), + throwsA( + isA().having((ArgumentError error) => error.message, + 'message', 'No channel registered with name js_channel_4.'), + )); + }); + + test( + 'updateJavascriptChannelsFromSet should clear all channels when null is supplied.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect(registry.channels.length, 3); + + registry.updateJavascriptChannelsFromSet(null); + + expect(registry.channels, isEmpty); + }); + + test('updateJavascriptChannelsFromSet should update registry with new set.', + () { + final JavascriptChannelRegistry registry = + JavascriptChannelRegistry(_channels); + + expect(registry.channels.length, 3); + + final Set newChannels = { + JavascriptChannel( + name: 'new_js_channel_1', + onMessageReceived: (JavascriptMessage message) => + _log['new_js_channel_1'] = message.message, + ), + JavascriptChannel( + name: 'new_js_channel_2', + onMessageReceived: (JavascriptMessage message) => + _log['new_js_channel_2'] = message.message, + ), + }; + + registry.updateJavascriptChannelsFromSet(newChannels); + + expect(registry.channels.length, 2); + for (final JavascriptChannel channel in newChannels) { + expect(registry.channels[channel.name], channel); + } + }); +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/javascript_channel_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/javascript_channel_test.dart new file mode 100644 index 000000000000..f481edda1edd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/javascript_channel_test.dart @@ -0,0 +1,48 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/src/types/javascript_channel.dart'; + +void main() { + final List _validChars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_'.split(''); + final List _commonInvalidChars = + r'`~!@#$%^&*()-=+[]{}\|"' ':;/?<>,. '.split(''); + final List _digits = List.generate(10, (int index) => index++); + + test( + 'ctor should create JavascriptChannel when name starts with a valid character followed by a number.', + () { + for (final String char in _validChars) { + for (final int digit in _digits) { + final JavascriptChannel channel = + JavascriptChannel(name: '$char$digit', onMessageReceived: (_) {}); + + expect(channel.name, '$char$digit'); + } + } + }); + + test('ctor should assert when channel name starts with a number.', () { + for (final int i in _digits) { + expect( + () => JavascriptChannel(name: '$i', onMessageReceived: (_) {}), + throwsAssertionError, + ); + } + }); + + test('ctor should assert when channel contains invalid char.', () { + for (final String validChar in _validChars) { + for (final String invalidChar in _commonInvalidChars) { + expect( + () => JavascriptChannel( + name: validChar + invalidChar, onMessageReceived: (_) {}), + throwsAssertionError, + ); + } + } + }); +} diff --git a/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/webview_request_test.dart b/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/webview_request_test.dart new file mode 100644 index 000000000000..5d2b568fe5dd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_platform_interface/test/src/types/webview_request_test.dart @@ -0,0 +1,39 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/src/types/types.dart'; + +void main() { + test('WebViewRequestMethod should serialize correctly', () { + expect(WebViewRequestMethod.get.serialize(), 'get'); + expect(WebViewRequestMethod.post.serialize(), 'post'); + }); + + test('WebViewRequest should serialize correctly', () { + WebViewRequest request; + Map serializedRequest; + // Test serialization without headers or a body + request = WebViewRequest( + uri: Uri.parse('https://flutter.dev'), + method: WebViewRequestMethod.get, + ); + serializedRequest = request.toJson(); + expect(serializedRequest['uri'], 'https://flutter.dev'); + expect(serializedRequest['method'], 'get'); + expect(serializedRequest['headers'], {}); + expect(serializedRequest['body'], null); + // Test serialization of headers and body + request = WebViewRequest( + uri: Uri.parse('https://flutter.dev'), + method: WebViewRequestMethod.get, + headers: {'foo': 'bar'}, + body: Uint8List.fromList('Example Body'.codeUnits), + ); + serializedRequest = request.toJson(); + expect(serializedRequest['headers'], {'foo': 'bar'}); + expect(serializedRequest['body'], 'Example Body'.codeUnits); + }); +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/AUTHORS b/packages/webview_flutter/webview_flutter_wkwebview/AUTHORS new file mode 100644 index 000000000000..4fa8b35fca8a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/AUTHORS @@ -0,0 +1,69 @@ +# Below is a list of people and organizations that have contributed +# to the Flutter project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +The Chromium Authors +German Saprykin +Benjamin Sauer +larsenthomasj@gmail.com +Ali Bitek +Pol Batlló +Anatoly Pulyaevskiy +Hayden Flinner +Stefano Rodriguez +Salvatore Giordano +Brian Armstrong +Paul DeMarco +Fabricio Nogueira +Simon Lightfoot +Ashton Thomas +Thomas Danner +Diego Velásquez +Hajime Nakamura +Tuyển Vũ Xuân +Miguel Ruivo +Sarthak Verma +Mike Diarmid +Invertase +Elliot Hesp +Vince Varga +Aawaz Gyawali +EUI Limited +Katarina Sheremet +Thomas Stockx +Sarbagya Dhaubanjar +Ozkan Eksi +Rishab Nayak +ko2ic +Jonathan Younger +Jose Sanchez +Debkanchan Samadder +Audrius Karosevicius +Lukasz Piliszczuk +SoundReply Solutions GmbH +Rafal Wachol +Pau Picas +Christian Weder +Alexandru Tuca +Christian Weder +Rhodes Davis Jr. +Luigi Agosti +Quentin Le Guennec +Koushik Ravikumar +Nissim Dsilva +Giancarlo Rocha +Ryo Miyake +Théo Champion +Kazuki Yamaguchi +Eitan Schwartz +Chris Rutkowski +Juan Alvarez +Aleksandr Yurkovskiy +Anton Borries +Alex Li +Rahul Raj <64.rahulraj@gmail.com> +Maurits van Beusekom +Antonino Di Natale +Nick Bradshaw diff --git a/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md b/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md new file mode 100644 index 000000000000..4db6dbfd2864 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/CHANGELOG.md @@ -0,0 +1,15 @@ +## 2.2.0 + +* Implemented new `runJavascript` and `runJavascriptReturningResult` methods in platform interface. + +## 2.1.0 + +* Add `zoomEnabled` functionality. + +## 2.0.14 + +* Update example App so navigation menu loads immediatly but only becomes available when `WebViewController` is available (same behavior as example App in webview_flutter package). + +## 2.0.13 + +* Extract WKWebView implementation from `webview_flutter`. diff --git a/packages/webview_flutter/webview_flutter_wkwebview/LICENSE b/packages/webview_flutter/webview_flutter_wkwebview/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/LICENSE @@ -0,0 +1,25 @@ +Copyright 2013 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/webview_flutter/webview_flutter_wkwebview/README.md b/packages/webview_flutter/webview_flutter_wkwebview/README.md new file mode 100644 index 000000000000..2e3a87b7f310 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/README.md @@ -0,0 +1,11 @@ +# webview\_flutter\_wkwebview + +The Apple WKWebView implementation of [`webview_flutter`][1]. + +## Usage + +This package is [endorsed][2], which means you can simply use `webview_flutter` +normally. This package will be automatically included in your app when you do. + +[1]: https://pub.dev/packages/webview_flutter +[2]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/.metadata b/packages/webview_flutter/webview_flutter_wkwebview/example/.metadata new file mode 100644 index 000000000000..da83b1ada1bd --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/.metadata @@ -0,0 +1,8 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 1e5cb2d87f8542f9fbbd0f22d528823274be0acb + channel: master diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/README.md b/packages/webview_flutter/webview_flutter_wkwebview/example/README.md new file mode 100644 index 000000000000..850ee74397a9 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/README.md @@ -0,0 +1,8 @@ +# webview_flutter_example + +Demonstrates how to use the webview_flutter plugin. + +## Getting Started + +For help getting started with Flutter, view our online +[documentation](https://flutter.dev/). diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_audio.ogg b/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_audio.ogg new file mode 100644 index 000000000000..27e17104277b Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_audio.ogg differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_video.mp4 b/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_video.mp4 new file mode 100644 index 000000000000..a203d0cdf13e Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/assets/sample_video.mp4 differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart new file mode 100644 index 000000000000..17e896c94888 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/integration_test/webview_flutter_test.dart @@ -0,0 +1,1237 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:webview_flutter_wkwebview_example/navigation_decision.dart'; +import 'package:webview_flutter_wkwebview_example/navigation_request.dart'; +import 'package:webview_flutter_wkwebview_example/web_view.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // URLs to navigate to in tests. These need to be URLs that we are confident will + // always be accessible, and won't do redirection. (E.g., just + // 'https://www.google.com/' will sometimes redirect traffic that looks + // like it's coming from a bot, which is true of these tests). + const String primaryUrl = 'https://flutter.dev/'; + const String secondaryUrl = 'https://www.google.com/robots.txt'; + + // Set to `false` to include all flaky tests in the test run. See also https://github.com/flutter/flutter/issues/86757. + const bool _skipDueToIssue86757 = false; + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('initialUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }, skip: _skipDueToIssue86757); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets('loadUrl', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.loadUrl(secondaryUrl); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }, skip: _skipDueToIssue86757); + + testWidgets('evaluateJavascript', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String result = await controller.evaluateJavascript('1 + 1'); + expect(result, equals('2')); + }); + + testWidgets('loadUrl with headers', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageStarts = StreamController(); + final StreamController pageLoads = StreamController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarts.add(url); + }, + onPageFinished: (String url) { + pageLoads.add(url); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final Map headers = { + 'test_header': 'flutter_test_header' + }; + await controller.loadUrl('https://flutter-header-echo.herokuapp.com/', + headers: headers); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, 'https://flutter-header-echo.herokuapp.com/'); + + await pageStarts.stream.firstWhere((String url) => url == currentUrl); + await pageLoads.stream.firstWhere((String url) => url == currentUrl); + + final String content = await controller + .runJavascriptReturningResult('document.documentElement.innerText'); + expect(content.contains('flutter_test_header'), isTrue); + }); + + testWidgets('JavascriptChannel', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final List messagesReceived = []; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + // This is the data URL for: '' + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'Echo', + onMessageReceived: (JavascriptMessage message) { + messagesReceived.add(message.message); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(messagesReceived, isEmpty); + await controller.runJavascript('Echo.postMessage("hello");'); + expect(messagesReceived, equals(['hello'])); + }); + + testWidgets('resize webview', (WidgetTester tester) async { + final String resizeTest = ''' + + Resize test + + + + + + '''; + final String resizeTestBase64 = + base64Encode(const Utf8Encoder().convert(resizeTest)); + final Completer resizeCompleter = Completer(); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + final GlobalKey key = GlobalKey(); + + final WebView webView = WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$resizeTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptChannels: { + JavascriptChannel( + name: 'Resize', + onMessageReceived: (JavascriptMessage message) { + resizeCompleter.complete(true); + }, + ), + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + javascriptMode: JavascriptMode.unrestricted, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 200, + height: 200, + child: webView, + ), + ], + ), + ), + ); + + await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + expect(resizeCompleter.isCompleted, false); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + SizedBox( + width: 400, + height: 400, + child: webView, + ), + ], + ), + ), + ); + + await resizeCompleter.future; + }); + + testWidgets('set custom userAgent', (WidgetTester tester) async { + final Completer controllerCompleter1 = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent1', + onWebViewCreated: (WebViewController controller) { + controllerCompleter1.complete(controller); + }, + ), + ), + ); + final WebViewController controller1 = await controllerCompleter1.future; + final String customUserAgent1 = await _getUserAgent(controller1); + expect(customUserAgent1, 'Custom_User_Agent1'); + // rebuild the WebView with a different user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent2', + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller1); + expect(customUserAgent2, 'Custom_User_Agent2'); + }); + + testWidgets('use default platform userAgent after webView is rebuilt', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final GlobalKey _globalKey = GlobalKey(); + // Build the webView with no user agent to get the default platform user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: primaryUrl, + javascriptMode: JavascriptMode.unrestricted, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String defaultPlatformUserAgent = await _getUserAgent(controller); + // rebuild the WebView with a custom user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + userAgent: 'Custom_User_Agent', + ), + ), + ); + final String customUserAgent = await _getUserAgent(controller); + expect(customUserAgent, 'Custom_User_Agent'); + // rebuilds the WebView with no user agent. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: _globalKey, + initialUrl: 'about:blank', + javascriptMode: JavascriptMode.unrestricted, + ), + ), + ); + + final String customUserAgent2 = await _getUserAgent(controller); + expect(customUserAgent2, defaultPlatformUserAgent); + }); + + group('Video playback policy', () { + late String videoTestBase64; + setUpAll(() async { + final ByteData videoData = + await rootBundle.load('assets/sample_video.mp4'); + final String base64VideoData = + base64Encode(Uint8List.view(videoData.buffer)); + final String videoTest = ''' + + Video auto play + + + + + + + '''; + videoTestBase64 = base64Encode(const Utf8Encoder().convert(videoTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolicy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + + testWidgets('Video plays inline when allowsInlineMediaPlayback is true', + (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: true, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.runJavascriptReturningResult('isFullScreen();'); + expect(fullScreen, _webviewBool(false)); + }); + + testWidgets( + 'Video plays full screen when allowsInlineMediaPlayback is false', + (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + Completer videoPlaying = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$videoTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + javascriptChannels: { + JavascriptChannel( + name: 'VideoTestTime', + onMessageReceived: (JavascriptMessage message) { + final double currentTime = double.parse(message.message); + // Let it play for at least 1 second to make sure the related video's properties are set. + if (currentTime > 1) { + videoPlaying.complete(null); + } + }, + ), + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + allowsInlineMediaPlayback: false, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + // Pump once to trigger the video play. + await tester.pump(); + + // Makes sure we get the correct event that indicates the video is actually playing. + await videoPlaying.future; + + String fullScreen = + await controller.runJavascriptReturningResult('isFullScreen();'); + expect(fullScreen, _webviewBool(true)); + }); + }); + + group('Audio playback policy', () { + late String audioTestBase64; + setUpAll(() async { + final ByteData audioData = + await rootBundle.load('assets/sample_audio.ogg'); + final String base64AudioData = + base64Encode(Uint8List.view(audioData.buffer)); + final String audioTest = ''' + + Audio auto play + + + + + + + '''; + audioTestBase64 = base64Encode(const Utf8Encoder().convert(audioTest)); + }); + + testWidgets('Auto media playback', (WidgetTester tester) async { + Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + controllerCompleter = Completer(); + pageStarted = Completer(); + pageLoaded = Completer(); + + // We change the key to re-create a new webview as we change the initialMediaPlaybackPolicy + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(true)); + }); + + testWidgets('Changes to initialMediaPlaybackPolocy are ignored', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageStarted = Completer(); + Completer pageLoaded = Completer(); + + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: AutoMediaPlaybackPolicy.always_allow, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + String isPaused = + await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + + pageStarted = Completer(); + pageLoaded = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: key, + initialUrl: 'data:text/html;charset=utf-8;base64,$audioTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + initialMediaPlaybackPolicy: + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + ), + ), + ); + + await controller.reload(); + + await pageStarted.future; + await pageLoaded.future; + + isPaused = await controller.runJavascriptReturningResult('isPaused();'); + expect(isPaused, _webviewBool(false)); + }); + }); + + testWidgets('getTitle', (WidgetTester tester) async { + final String getTitleTest = ''' + + Some title + + + + + '''; + final String getTitleTestBase64 = + base64Encode(const Utf8Encoder().convert(getTitleTest)); + final Completer pageStarted = Completer(); + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: 'data:text/html;charset=utf-8;base64,$getTitleTestBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageStarted: (String url) { + pageStarted.complete(null); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageStarted.future; + await pageLoaded.future; + + final String? title = await controller.getTitle(); + expect(title, 'Some title'); + }); + + group('Programmatic Scroll', () { + testWidgets('setAndGetScrollPosition', (WidgetTester tester) async { + final String scrollTestPage = ''' + + + + + + +
+ + + '''; + + final String scrollTestPageBase64 = + base64Encode(const Utf8Encoder().convert(scrollTestPage)); + + final Completer pageLoaded = Completer(); + final Completer controllerCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + initialUrl: + 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + + final WebViewController controller = await controllerCompleter.future; + await pageLoaded.future; + + await tester.pumpAndSettle(Duration(seconds: 3)); + + int scrollPosX = await controller.getScrollX(); + int scrollPosY = await controller.getScrollY(); + + // Check scrollTo() + const int X_SCROLL = 123; + const int Y_SCROLL = 321; + // Get the initial position; this ensures that scrollTo is actually + // changing something, but also gives the native view's scroll position + // time to settle. + expect(scrollPosX, isNot(X_SCROLL)); + expect(scrollPosX, isNot(Y_SCROLL)); + + await controller.scrollTo(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL); + expect(scrollPosY, Y_SCROLL); + + // Check scrollBy() (on top of scrollTo()) + await controller.scrollBy(X_SCROLL, Y_SCROLL); + scrollPosX = await controller.getScrollX(); + scrollPosY = await controller.getScrollY(); + expect(scrollPosX, X_SCROLL * 2); + expect(scrollPosY, Y_SCROLL * 2); + }); + }); + + group('NavigationDelegate', () { + final String blankPage = ""; + final String blankPageEncoded = 'data:text/html;charset=utf-8;base64,' + + base64Encode(const Utf8Encoder().convert(blankPage)); + + testWidgets('can allow requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('location.href = "$secondaryUrl"'); + + await pageLoads.stream.first; // Wait for the next page load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }); + + testWidgets('onWebResourceError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: 'https://www.notawebsite..com', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + final WebResourceError error = await errorCompleter.future; + expect(error, isNotNull); + + if (Platform.isIOS) { + expect(error.domain, isNotNull); + expect(error.failingUrl, isNull); + } else if (Platform.isAndroid) { + expect(error.errorType, isNotNull); + expect(error.failingUrl?.startsWith('https://www.notawebsite..com'), + isTrue); + } + }); + + testWidgets('onWebResourceError is not called with valid url', + (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + + testWidgets( + 'onWebResourceError only called for main frame', + (WidgetTester tester) async { + final String iframeTest = ''' + + + + WebResourceError test + + + + + + '''; + final String iframeTestBase64 = + base64Encode(const Utf8Encoder().convert(iframeTest)); + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: + 'data:text/html;charset=utf-8;base64,$iframeTestBase64', + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, + onPageFinished: (_) => pageFinishCompleter.complete(), + ), + ), + ); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }, + ); + + testWidgets('can block requests', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + return (request.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller + .runJavascript('location.href = "https://www.youtube.com/"'); + + // There should never be any second page load, since our new URL is + // blocked. Still wait for a potential page change for some time in order + // to give the test a chance to fail. + await pageLoads.stream.first + .timeout(const Duration(milliseconds: 500), onTimeout: () => ''); + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, isNot(contains('youtube.com'))); + }); + + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final StreamController pageLoads = + StreamController.broadcast(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + initialUrl: blankPageEncoded, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate); + return decision; + }, + onPageFinished: (String url) => pageLoads.add(url), + ), + ), + ); + + await pageLoads.stream.first; // Wait for initial page load. + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('location.href = "$secondaryUrl"'); + + await pageLoads.stream.first; // Wait for second page to load. + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, secondaryUrl); + }); + }); + + testWidgets('launches with gestureNavigationEnabled on iOS', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 300, + child: WebView( + key: GlobalKey(), + initialUrl: primaryUrl, + gestureNavigationEnabled: true, + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + ), + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }); + + testWidgets('target _blank opens in same window', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + final Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(null); + }, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + await controller.runJavascript('window.open("$primaryUrl", "_blank")'); + await pageLoaded.future; + final String? currentUrl = await controller.currentUrl(); + expect(currentUrl, primaryUrl); + }); + + // TODO(bparrishMines): skipped due to https://github.com/flutter/flutter/issues/86757. + testWidgets( + 'can open new window and go back', + (WidgetTester tester) async { + final Completer controllerCompleter = + Completer(); + Completer pageLoaded = Completer(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WebView( + key: GlobalKey(), + onWebViewCreated: (WebViewController controller) { + controllerCompleter.complete(controller); + }, + javascriptMode: JavascriptMode.unrestricted, + onPageFinished: (String url) { + pageLoaded.complete(); + }, + initialUrl: primaryUrl, + ), + ), + ); + final WebViewController controller = await controllerCompleter.future; + expect(controller.currentUrl(), completion(primaryUrl)); + await pageLoaded.future; + pageLoaded = Completer(); + + await controller.runJavascript('window.open("$secondaryUrl")'); + await pageLoaded.future; + pageLoaded = Completer(); + expect(controller.currentUrl(), completion(secondaryUrl)); + + expect(controller.canGoBack(), completion(true)); + await controller.goBack(); + await pageLoaded.future; + expect(controller.currentUrl(), completion(primaryUrl)); + }, + skip: _skipDueToIssue86757, + ); +} + +// JavaScript booleans evaluate to different string values on Android and iOS. +// This utility method returns the string boolean value of the current platform. +String _webviewBool(bool value) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return value ? '1' : '0'; + } + return value ? 'true' : 'false'; +} + +/// Returns the value used for the HTTP User-Agent: request header in subsequent HTTP requests. +Future _getUserAgent(WebViewController controller) async { + return _runJavascriptReturningResult(controller, 'navigator.userAgent;'); +} + +Future _runJavascriptReturningResult( + WebViewController controller, String js) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return await controller.runJavascriptReturningResult(js); + } + return jsonDecode(await controller.runJavascriptReturningResult(js)); +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/AppFrameworkInfo.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000000..8d4492f977ad --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Debug.xcconfig b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000000..e8efba114687 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Release.xcconfig b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000000..399e9340e6f6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Podfile b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Podfile new file mode 100644 index 000000000000..66509fcae284 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Podfile @@ -0,0 +1,45 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '9.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + target 'RunnerTests' do + inherit! :search_paths + + # Matches test_spec dependency. + pod 'OCMock', '3.5' + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.pbxproj b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..ba0deb4781d4 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,727 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */; }; + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; + 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + AE8C124DC8CA68E4D9B30EAB /* libPods-RunnerTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 528CB85D53C983D2C5DAFDC5 /* libPods-RunnerTests.a */; }; + DAF0E91266956134538CC667 /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 572FFC2B2BA326B420B22679 /* libPods-Runner.a */; }; + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = F7151F76266057800028CB91 /* FLTWebViewUITests.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 68BDCAEE23C3F7CB00D9C032 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; + F7151F79266057800028CB91 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 528CB85D53C983D2C5DAFDC5 /* libPods-RunnerTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RunnerTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 572FFC2B2BA326B420B22679 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5C776D27D0DDA247ED5EA72B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWKNavigationDelegateTests.m; sourceTree = ""; }; + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 68BDCAED23C3F7CB00D9C032 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewTests.m; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B89AA31A64040E4A2F1E0CAF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + C370F140C3A19241FD8C5E64 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + F7151F74266057800028CB91 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F7151F76266057800028CB91 /* FLTWebViewUITests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FLTWebViewUITests.m; sourceTree = ""; }; + F7151F78266057800028CB91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F7A1921261392D1CBDAEC2E8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 68BDCAE623C3F7CB00D9C032 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AE8C124DC8CA68E4D9B30EAB /* libPods-RunnerTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DAF0E91266956134538CC667 /* libPods-Runner.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F71266057800028CB91 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 52FBC2B567345431F81A0A0F /* Frameworks */ = { + isa = PBXGroup; + children = ( + 572FFC2B2BA326B420B22679 /* libPods-Runner.a */, + 528CB85D53C983D2C5DAFDC5 /* libPods-RunnerTests.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 686B4BF82548DBC7000AEA36 /* FLTWKNavigationDelegateTests.m */, + 68BDCAF523C3F97800D9C032 /* FLTWebViewTests.m */, + 68BDCAED23C3F7CB00D9C032 /* Info.plist */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 68BDCAEA23C3F7CB00D9C032 /* RunnerTests */, + F7151F75266057800028CB91 /* RunnerUITests */, + 97C146EF1CF9000F007C117D /* Products */, + B8AEEA11D6ECBD09750349AE /* Pods */, + 52FBC2B567345431F81A0A0F /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */, + F7151F74266057800028CB91 /* RunnerUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */, + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 97C146F21CF9000F007C117D /* main.m */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + B8AEEA11D6ECBD09750349AE /* Pods */ = { + isa = PBXGroup; + children = ( + F7A1921261392D1CBDAEC2E8 /* Pods-Runner.debug.xcconfig */, + B89AA31A64040E4A2F1E0CAF /* Pods-Runner.release.xcconfig */, + C370F140C3A19241FD8C5E64 /* Pods-RunnerTests.debug.xcconfig */, + 5C776D27D0DDA247ED5EA72B /* Pods-RunnerTests.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + F7151F75266057800028CB91 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + F7151F76266057800028CB91 /* FLTWebViewUITests.m */, + F7151F78266057800028CB91 /* Info.plist */, + ); + path = RunnerUITests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 0067CEC0658A36CBFF8074E7 /* [CP] Check Pods Manifest.lock */, + 68BDCAE523C3F7CB00D9C032 /* Sources */, + 68BDCAE623C3F7CB00D9C032 /* Frameworks */, + 68BDCAE723C3F7CB00D9C032 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 68BDCAEF23C3F7CB00D9C032 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = webview_flutter_exampleTests; + productReference = 68BDCAE923C3F7CB00D9C032 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 6F536C27DD48B395A30EBB65 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; + F7151F73266057800028CB91 /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + F7151F70266057800028CB91 /* Sources */, + F7151F71266057800028CB91 /* Frameworks */, + F7151F72266057800028CB91 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + F7151F7A266057800028CB91 /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = F7151F74266057800028CB91 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + DefaultBuildSystemTypeForWorkspace = Original; + LastUpgradeCheck = 1030; + ORGANIZATIONNAME = "The Flutter Authors"; + TargetAttributes = { + 68BDCAE823C3F7CB00D9C032 = { + ProvisioningStyle = Automatic; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + }; + F7151F73266057800028CB91 = { + CreatedOnToolsVersion = 12.5; + ProvisioningStyle = Automatic; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 68BDCAE823C3F7CB00D9C032 /* RunnerTests */, + F7151F73266057800028CB91 /* RunnerUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 68BDCAE723C3F7CB00D9C032 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F72266057800028CB91 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0067CEC0658A36CBFF8074E7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n"; + }; + 6F536C27DD48B395A30EBB65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 68BDCAE523C3F7CB00D9C032 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 334734012669319100DCC49E /* FLTWebViewTests.m in Sources */, + 334734022669319400DCC49E /* FLTWKNavigationDelegateTests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */, + 97C146F31CF9000F007C117D /* main.m in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F7151F70266057800028CB91 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F7151F77266057800028CB91 /* FLTWebViewUITests.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 68BDCAEF23C3F7CB00D9C032 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 68BDCAEE23C3F7CB00D9C032 /* PBXContainerItemProxy */; + }; + F7151F7A266057800028CB91 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = F7151F79266057800028CB91 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 68BDCAF023C3F7CB00D9C032 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C370F140C3A19241FD8C5E64 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; + 68BDCAF123C3F7CB00D9C032 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5C776D27D0DDA247ED5EA72B /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Release; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.webviewFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + F7151F7C266057800028CB91 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + F7151F7D266057800028CB91 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = RunnerUITests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + TEST_TARGET_NAME = Runner; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 68BDCAF223C3F7CB00D9C032 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 68BDCAF023C3F7CB00D9C032 /* Debug */, + 68BDCAF123C3F7CB00D9C032 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F7151F7B266057800028CB91 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7151F7C266057800028CB91 /* Debug */, + F7151F7D266057800028CB91 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000000..d7453a8ce862 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..21a3cc14c74e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.h b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.h new file mode 100644 index 000000000000..0681d288bb70 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.h @@ -0,0 +1,10 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +@interface AppDelegate : FlutterAppDelegate + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.m new file mode 100644 index 000000000000..30b87969f44a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/AppDelegate.m @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "AppDelegate.h" +#include "GeneratedPluginRegistrant.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [GeneratedPluginRegistrant registerWithRegistry:self]; + // Override point for customization after application launch. + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..d36b1fab2d9d --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 000000000000..3d43d11e66f4 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 000000000000..28c6bf03016f Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000000..f091b6b0bca8 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 000000000000..4cde12118dda Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000000..d0ef06e7edb8 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000000..dcdc2306c285 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000000..c8f9ed8f5cee Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000000..75b2d164a5a9 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 000000000000..c4df70d39da7 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000000..6a84f41e14e2 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000000..d0e1f5853602 Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 000000000000..0bedcf2fd467 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 000000000000..89c2725b70f1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000000..f2e259c7c939 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/Main.storyboard b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000000..f3c28516fb38 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Info.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Info.plist new file mode 100644 index 000000000000..a810c5a172c0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + webview_flutter_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/main.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/main.m new file mode 100644 index 000000000000..f97b9ef5c8a1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/Runner/main.m @@ -0,0 +1,13 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m new file mode 100644 index 000000000000..a819a9b53d60 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWKNavigationDelegateTests.m @@ -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. + +@import Flutter; +@import XCTest; +@import webview_flutter_wkwebview; + +// OCMock library doesn't generate a valid modulemap. +#import + +@interface FLTWKNavigationDelegateTests : XCTestCase + +@property(strong, nonatomic) FlutterMethodChannel *mockMethodChannel; +@property(strong, nonatomic) FLTWKNavigationDelegate *navigationDelegate; +@property(strong, nonatomic) WKNavigation *navigation; + +@end + +@implementation FLTWKNavigationDelegateTests + +NSString *const zoomDisablingJavascript = + @"var meta = document.createElement('meta');" + @"meta.name = 'viewport';" + @"meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0," + @"user-scalable=no';" + @"var head = document.getElementsByTagName('head')[0];head.appendChild(meta);"; + +- (void)setUp { + self.mockMethodChannel = OCMClassMock(FlutterMethodChannel.class); + self.navigationDelegate = + [[FLTWKNavigationDelegate alloc] initWithChannel:self.mockMethodChannel]; +} + +- (void)testWebViewWebContentProcessDidTerminateCallsRecourseErrorChannel { + if (@available(iOS 9.0, *)) { + // `webViewWebContentProcessDidTerminate` is only available on iOS 9.0 and above. + WKWebView *webview = OCMClassMock(WKWebView.class); + [self.navigationDelegate webViewWebContentProcessDidTerminate:webview]; + OCMVerify([self.mockMethodChannel + invokeMethod:@"onWebResourceError" + arguments:[OCMArg checkWithBlock:^BOOL(NSDictionary *args) { + XCTAssertEqualObjects(args[@"errorType"], @"webContentProcessTerminated"); + return true; + }]]); + } +} + +- (void)testWebViewWebEvaluateJavaScriptSourceIsCorrectWhenShouldEnableZoomIsFalse { + WKWebView *webview = OCMClassMock(WKWebView.class); + WKNavigation *navigation = OCMClassMock(WKNavigation.class); + NSURL *testUrl = [[NSURL alloc] initWithString:@"www.example.com"]; + OCMStub([webview URL]).andReturn(testUrl); + + self.navigationDelegate.shouldEnableZoom = false; + [self.navigationDelegate webView:webview didFinishNavigation:navigation]; + OCMVerify([webview evaluateJavaScript:zoomDisablingJavascript completionHandler:nil]); +} + +- (void)testWebViewWebEvaluateJavaScriptShouldNotBeCalledWhenShouldEnableZoomIsTrue { + WKWebView *webview = OCMClassMock(WKWebView.class); + WKNavigation *navigation = OCMClassMock(WKNavigation.class); + NSURL *testUrl = [[NSURL alloc] initWithString:@"www.example.com"]; + OCMStub([webview URL]).andReturn(testUrl); + + self.navigationDelegate.shouldEnableZoom = true; + + OCMReject([webview evaluateJavaScript:zoomDisablingJavascript completionHandler:nil]); + [self.navigationDelegate webView:webview didFinishNavigation:navigation]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWebViewTests.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWebViewTests.m new file mode 100644 index 000000000000..9d127c2c4aaa --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/FLTWebViewTests.m @@ -0,0 +1,304 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import Flutter; +@import XCTest; +@import webview_flutter_wkwebview; + +// OCMock library doesn't generate a valid modulemap. +#import + +static bool feq(CGFloat a, CGFloat b) { return fabs(b - a) < FLT_EPSILON; } + +@interface FLTWebViewTests : XCTestCase + +@property(strong, nonatomic) NSObject *mockBinaryMessenger; + +@end + +@implementation FLTWebViewTests + +- (void)setUp { + [super setUp]; + self.mockBinaryMessenger = OCMProtocolMock(@protocol(FlutterBinaryMessenger)); +} + +- (void)testCanInitFLTWebViewController { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTAssertNotNil(controller); +} + +- (void)testCanInitFLTWebViewFactory { + FLTWebViewFactory *factory = + [[FLTWebViewFactory alloc] initWithMessenger:self.mockBinaryMessenger]; + XCTAssertNotNil(factory); +} + +- (void)webViewContentInsetBehaviorShouldBeNeverOnIOS11 { + if (@available(iOS 11, *)) { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + UIView *view = controller.view; + XCTAssertTrue([view isKindOfClass:WKWebView.class]); + WKWebView *webView = (WKWebView *)view; + XCTAssertEqual(webView.scrollView.contentInsetAdjustmentBehavior, + UIScrollViewContentInsetAdjustmentNever); + } +} + +- (void)testWebViewScrollIndicatorAticautomaticallyAdjustsScrollIndicatorInsetsShouldbeNoOnIOS13 { + if (@available(iOS 13, *)) { + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + UIView *view = controller.view; + XCTAssertTrue([view isKindOfClass:WKWebView.class]); + WKWebView *webView = (WKWebView *)view; + XCTAssertFalse(webView.scrollView.automaticallyAdjustsScrollIndicatorInsets); + } +} + +- (void)testContentInsetsSumAlwaysZeroAfterSetFrame { + FLTWKWebView *webView = [[FLTWKWebView alloc] initWithFrame:CGRectMake(0, 0, 300, 400)]; + webView.scrollView.contentInset = UIEdgeInsetsMake(0, 0, 300, 0); + XCTAssertFalse(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + webView.frame = CGRectMake(0, 0, 300, 200); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + XCTAssertTrue(CGRectEqualToRect(webView.frame, CGRectMake(0, 0, 300, 200))); + + if (@available(iOS 11, *)) { + // After iOS 11, we need to make sure the contentInset compensates the adjustedContentInset. + UIScrollView *partialMockScrollView = OCMPartialMock(webView.scrollView); + UIEdgeInsets insetToAdjust = UIEdgeInsetsMake(0, 0, 300, 0); + OCMStub(partialMockScrollView.adjustedContentInset).andReturn(insetToAdjust); + XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(webView.scrollView.contentInset, UIEdgeInsetsZero)); + webView.frame = CGRectMake(0, 0, 300, 100); + XCTAssertTrue(feq(webView.scrollView.contentInset.bottom, -insetToAdjust.bottom)); + XCTAssertTrue(CGRectEqualToRect(webView.frame, CGRectMake(0, 0, 300, 100))); + } +} + +- (void)testRunJavascriptFailsForNullString { + // Setup + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Should return error result over the method channel."]; + + // Run + [controller onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"runJavascript" + arguments:nil] + result:^(id _Nullable result) { + XCTAssertTrue([result class] == [FlutterError class]); + [resultExpectation fulfill]; + }]; + + // Verify + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +- (void)testRunJavascriptRunsStringWithSuccessResult { + // Setup + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Should return successful result over the method channel."]; + FLTWKWebView *mockView = OCMClassMock(FLTWKWebView.class); + [OCMStub([mockView evaluateJavaScript:[OCMArg any] + completionHandler:[OCMArg any]]) andDo:^(NSInvocation *invocation) { + // __unsafe_unretained: https://github.com/erikdoe/ocmock/issues/384#issuecomment-589376668 + __unsafe_unretained void (^evalResultHandler)(id, NSError *); + [invocation getArgument:&evalResultHandler atIndex:3]; + evalResultHandler(@"RESULT", nil); + }]; + controller.webView = mockView; + + // Run + [controller onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"runJavascript" + arguments:@"Test JavaScript String"] + result:^(id _Nullable result) { + XCTAssertNil(result); + [resultExpectation fulfill]; + }]; + + // Verify + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +- (void)testRunJavascriptReturnsErrorResultForWKError { + // Setup + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Should return error result over the method channel."]; + NSError *testError = + [NSError errorWithDomain:@"" + // Any error code but WKErrorJavascriptResultTypeIsUnsupported + code:WKErrorJavaScriptResultTypeIsUnsupported + 1 + userInfo:@{NSLocalizedDescriptionKey : @"Test Error"}]; + FLTWKWebView *mockView = OCMClassMock(FLTWKWebView.class); + [OCMStub([mockView evaluateJavaScript:[OCMArg any] + completionHandler:[OCMArg any]]) andDo:^(NSInvocation *invocation) { + // __unsafe_unretained: https://github.com/erikdoe/ocmock/issues/384#issuecomment-589376668 + __unsafe_unretained void (^evalResultHandler)(id, NSError *); + [invocation getArgument:&evalResultHandler atIndex:3]; + evalResultHandler(nil, testError); + }]; + controller.webView = mockView; + + // Run + [controller onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"runJavascript" + arguments:@"Test JavaScript String"] + result:^(id _Nullable result) { + XCTAssertTrue([result class] == [FlutterError class]); + [resultExpectation fulfill]; + }]; + + // Verify + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +- (void)testRunJavascriptReturnsSuccessForWKErrorJavascriptResultTypeIsUnsupported { + // Setup + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Should return nil result over the method channel."]; + NSError *testError = [NSError errorWithDomain:@"" + code:WKErrorJavaScriptResultTypeIsUnsupported + userInfo:@{NSLocalizedDescriptionKey : @"Test Error"}]; + FLTWKWebView *mockView = OCMClassMock(FLTWKWebView.class); + [OCMStub([mockView evaluateJavaScript:[OCMArg any] + completionHandler:[OCMArg any]]) andDo:^(NSInvocation *invocation) { + // __unsafe_unretained: https://github.com/erikdoe/ocmock/issues/384#issuecomment-589376668 + __unsafe_unretained void (^evalResultHandler)(id, NSError *); + [invocation getArgument:&evalResultHandler atIndex:3]; + evalResultHandler(nil, testError); + }]; + controller.webView = mockView; + + // Run + [controller onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"runJavascript" + arguments:@"Test JavaScript String"] + result:^(id _Nullable result) { + XCTAssertNil(result); + [resultExpectation fulfill]; + }]; + + // Verify + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +- (void)testRunJavascriptReturningResultFailsForNullString { + // Setup + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Should return error result over the method channel."]; + + // Run + [controller + onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"runJavascriptReturningResult" + arguments:nil] + result:^(id _Nullable result) { + XCTAssertTrue([result class] == [FlutterError class]); + [resultExpectation fulfill]; + }]; + + // Verify + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +- (void)testRunJavascriptReturningResultRunsStringWithSuccessResult { + // Setup + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Should return successful result over the method channel."]; + FLTWKWebView *mockView = OCMClassMock(FLTWKWebView.class); + [OCMStub([mockView evaluateJavaScript:[OCMArg any] + completionHandler:[OCMArg any]]) andDo:^(NSInvocation *invocation) { + // __unsafe_unretained: https://github.com/erikdoe/ocmock/issues/384#issuecomment-589376668 + __unsafe_unretained void (^evalResultHandler)(id, NSError *); + [invocation getArgument:&evalResultHandler atIndex:3]; + evalResultHandler(@"RESULT", nil); + }]; + controller.webView = mockView; + + // Run + [controller + onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"runJavascriptReturningResult" + arguments:@"Test JavaScript String"] + result:^(id _Nullable result) { + XCTAssertTrue([@"RESULT" isEqualToString:result]); + [resultExpectation fulfill]; + }]; + + // Verify + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +- (void)testRunJavascriptReturningResultReturnsErrorResultForWKError { + // Setup + FLTWebViewController *controller = + [[FLTWebViewController alloc] initWithFrame:CGRectMake(0, 0, 300, 400) + viewIdentifier:1 + arguments:nil + binaryMessenger:self.mockBinaryMessenger]; + XCTestExpectation *resultExpectation = + [self expectationWithDescription:@"Should return error result over the method channel."]; + NSError *testError = [NSError errorWithDomain:@"" + code:5 + userInfo:@{NSLocalizedDescriptionKey : @"Test Error"}]; + FLTWKWebView *mockView = OCMClassMock(FLTWKWebView.class); + [OCMStub([mockView evaluateJavaScript:[OCMArg any] + completionHandler:[OCMArg any]]) andDo:^(NSInvocation *invocation) { + // __unsafe_unretained: https://github.com/erikdoe/ocmock/issues/384#issuecomment-589376668 + __unsafe_unretained void (^evalResultHandler)(id, NSError *); + [invocation getArgument:&evalResultHandler atIndex:3]; + evalResultHandler(nil, testError); + }]; + controller.webView = mockView; + + // Run + [controller + onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"runJavascriptReturningResult" + arguments:@"Test JavaScript String"] + result:^(id _Nullable result) { + XCTAssertTrue([result class] == [FlutterError class]); + [resultExpectation fulfill]; + }]; + + // Verify + [self waitForExpectationsWithTimeout:30.0 handler:nil]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/Info.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/FLTWebViewUITests.m b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/FLTWebViewUITests.m new file mode 100644 index 000000000000..d193be745972 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/FLTWebViewUITests.m @@ -0,0 +1,101 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import XCTest; +@import os.log; + +@interface FLTWebViewUITests : XCTestCase +@property(nonatomic, strong) XCUIApplication* app; +@end + +@implementation FLTWebViewUITests + +- (void)setUp { + self.continueAfterFailure = NO; + + self.app = [[XCUIApplication alloc] init]; + [self.app launch]; +} + +- (void)testUserAgent { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* userAgent = app.buttons[@"Show user agent"]; + if (![userAgent waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Show user agent"); + } + NSPredicate* userAgentPredicate = + [NSPredicate predicateWithFormat:@"label BEGINSWITH 'User Agent: Mozilla/5.0 (iPhone; '"]; + XCUIElement* userAgentPopUp = [app.otherElements elementMatchingPredicate:userAgentPredicate]; + XCTAssertFalse(userAgentPopUp.exists); + [userAgent tap]; + if (![userAgentPopUp waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find user agent pop up"); + } +} + +- (void)testCache { + XCUIApplication* app = self.app; + XCUIElement* menu = app.buttons[@"Show menu"]; + if (![menu waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find menu"); + } + [menu tap]; + + XCUIElement* clearCache = app.buttons[@"Clear cache"]; + if (![clearCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Clear cache"); + } + [clearCache tap]; + + [menu tap]; + + XCUIElement* listCache = app.buttons[@"List cache"]; + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* emptyCachePopup = app.otherElements[@"{\"cacheKeys\":[],\"localStorage\":{}}"]; + if (![emptyCachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find empty cache pop up"); + } + + [menu tap]; + XCUIElement* addCache = app.buttons[@"Add to cache"]; + if (![addCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find Add to cache"); + } + [addCache tap]; + [menu tap]; + + if (![listCache waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find List cache"); + } + [listCache tap]; + + XCUIElement* cachePopup = + app.otherElements[@"{\"cacheKeys\":[\"test_caches_entry\"],\"localStorage\":{\"test_" + @"localStorage\":\"dummy_entry\"}}"]; + if (![cachePopup waitForExistenceWithTimeout:30.0]) { + os_log_error(OS_LOG_DEFAULT, "%@", app.debugDescription); + XCTFail(@"Failed due to not able to find cache pop up"); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/Info.plist b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/Info.plist new file mode 100644 index 000000000000..64d65ca49577 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/ios/RunnerUITests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart new file mode 100644 index 000000000000..21240f63ec1a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/main.dart @@ -0,0 +1,344 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: public_member_api_docs + +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import 'navigation_decision.dart'; +import 'navigation_request.dart'; +import 'web_view.dart'; + +void main() { + runApp(MaterialApp(home: _WebViewExample())); +} + +const String kNavigationExamplePage = ''' + +Navigation Delegate Example + +

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

+ + + +'''; + +class _WebViewExample extends StatefulWidget { + const _WebViewExample({Key? key}) : super(key: key); + + @override + _WebViewExampleState createState() => _WebViewExampleState(); +} + +class _WebViewExampleState extends State<_WebViewExample> { + final Completer _controller = + Completer(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter WebView example'), + // This drop down menu demonstrates that Flutter widgets can be shown over the web view. + actions: [ + _NavigationControls(_controller.future), + _SampleMenu(_controller.future), + ], + ), + // We're using a Builder here so we have a context that is below the Scaffold + // to allow calling Scaffold.of(context) so we can show a snackbar. + body: Builder(builder: (context) { + return WebView( + initialUrl: 'https://flutter.dev', + onWebViewCreated: (WebViewController controller) { + _controller.complete(controller); + }, + javascriptChannels: _createJavascriptChannels(context), + javascriptMode: JavascriptMode.unrestricted, + navigationDelegate: (NavigationRequest request) { + if (request.url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $request}'); + return NavigationDecision.prevent; + } + print('allowing navigation to $request'); + return NavigationDecision.navigate; + }, + ); + }), + floatingActionButton: favoriteButton(), + ); + } + + Widget favoriteButton() { + return FutureBuilder( + future: _controller.future, + builder: (BuildContext context, + AsyncSnapshot controller) { + if (controller.hasData) { + return FloatingActionButton( + onPressed: () async { + final String url = (await controller.data!.currentUrl())!; + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar(content: Text('Favorited $url')), + ); + }, + child: const Icon(Icons.favorite), + ); + } + return Container(); + }); + } +} + +Set _createJavascriptChannels(BuildContext context) { + return { + JavascriptChannel( + name: 'Snackbar', + onMessageReceived: (JavascriptMessage message) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message.message))); + }), + }; +} + +enum _MenuOptions { + showUserAgent, + listCookies, + clearCookies, + addToCache, + listCache, + clearCache, + navigationDelegate, +} + +class _SampleMenu extends StatelessWidget { + _SampleMenu(this.controller); + + final Future controller; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: controller, + builder: + (BuildContext context, AsyncSnapshot controller) { + return PopupMenuButton<_MenuOptions>( + onSelected: (_MenuOptions value) { + switch (value) { + case _MenuOptions.showUserAgent: + _onShowUserAgent(controller.data!, context); + break; + case _MenuOptions.listCookies: + _onListCookies(controller.data!, context); + break; + case _MenuOptions.clearCookies: + _onClearCookies(controller.data!, context); + break; + case _MenuOptions.addToCache: + _onAddToCache(controller.data!, context); + break; + case _MenuOptions.listCache: + _onListCache(controller.data!, context); + break; + case _MenuOptions.clearCache: + _onClearCache(controller.data!, context); + break; + case _MenuOptions.navigationDelegate: + _onNavigationDelegateExample(controller.data!, context); + break; + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem<_MenuOptions>( + value: _MenuOptions.showUserAgent, + child: const Text('Show user agent'), + enabled: controller.hasData, + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCookies, + child: Text('List cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCookies, + child: Text('Clear cookies'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.addToCache, + child: Text('Add to cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.listCache, + child: Text('List cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.clearCache, + child: Text('Clear cache'), + ), + const PopupMenuItem<_MenuOptions>( + value: _MenuOptions.navigationDelegate, + child: Text('Navigation Delegate example'), + ), + ], + ); + }, + ); + } + + void _onShowUserAgent( + WebViewController controller, BuildContext context) async { + // Send a message with the user agent string to the Snackbar JavaScript channel we registered + // with the WebView. + await controller.runJavascript( + 'Snackbar.postMessage("User Agent: " + navigator.userAgent);'); + } + + void _onListCookies( + WebViewController controller, BuildContext context) async { + final String cookies = + await controller.runJavascriptReturningResult('document.cookie'); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Cookies:'), + _getCookieList(cookies), + ], + ), + )); + } + + void _onAddToCache(WebViewController controller, BuildContext context) async { + await controller.runJavascript( + 'caches.open("test_caches_entry"); localStorage["test_localStorage"] = "dummy_entry";'); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Added a test entry to cache.'), + )); + } + + void _onListCache(WebViewController controller, BuildContext context) async { + await controller.runJavascript('caches.keys()' + '.then((cacheKeys) => JSON.stringify({"cacheKeys" : cacheKeys, "localStorage" : localStorage}))' + '.then((caches) => Snackbar.postMessage(caches))'); + } + + void _onClearCache(WebViewController controller, BuildContext context) async { + await controller.clearCache(); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text("Cache cleared."), + )); + } + + void _onClearCookies( + WebViewController controller, BuildContext context) async { + final bool hadCookies = await WebView.platform.clearCookies(); + String message = 'There were cookies. Now, they are gone!'; + if (!hadCookies) { + message = 'There are no cookies.'; + } + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + )); + } + + void _onNavigationDelegateExample( + WebViewController controller, BuildContext context) async { + final String contentBase64 = + base64Encode(const Utf8Encoder().convert(kNavigationExamplePage)); + await controller.loadUrl('data:text/html;base64,$contentBase64'); + } + + Widget _getCookieList(String cookies) { + if (cookies == null || cookies == '""') { + return Container(); + } + final List cookieList = cookies.split(';'); + final Iterable cookieWidgets = + cookieList.map((String cookie) => Text(cookie)); + return Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: cookieWidgets.toList(), + ); + } +} + +class _NavigationControls extends StatelessWidget { + const _NavigationControls(this._webViewControllerFuture) + : assert(_webViewControllerFuture != null); + + final Future _webViewControllerFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _webViewControllerFuture, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + final bool webViewReady = + snapshot.connectionState == ConnectionState.done; + final WebViewController? controller = snapshot.data; + + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller!.canGoBack()) { + await controller.goBack(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar(content: Text("No back history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + onPressed: !webViewReady + ? null + : () async { + if (await controller!.canGoForward()) { + await controller.goForward(); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + const SnackBar( + content: Text("No forward history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.replay), + onPressed: !webViewReady + ? null + : () { + controller!.reload(); + }, + ), + ], + ); + }, + ); + } +} + +/// Callback type for handling messages sent from JavaScript running in a web view. +typedef void JavascriptMessageHandler(JavascriptMessage message); diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_decision.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_decision.dart new file mode 100644 index 000000000000..d8178acd8096 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_decision.dart @@ -0,0 +1,12 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A decision on how to handle a navigation request. +enum NavigationDecision { + /// Prevent the navigation from taking place. + prevent, + + /// Allow the navigation to take place. + navigate, +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_request.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_request.dart new file mode 100644 index 000000000000..c1ff8dc5a690 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/navigation_request.dart @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Information about a navigation action that is about to be executed. +class NavigationRequest { + NavigationRequest._({required this.url, required this.isForMainFrame}); + + /// The URL that will be loaded if the navigation is executed. + final String url; + + /// Whether the navigation request is to be loaded as the main frame. + final bool isForMainFrame; + + @override + String toString() { + return '$runtimeType(url: $url, isForMainFrame: $isForMainFrame)'; + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/lib/web_view.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/web_view.dart new file mode 100644 index 000000000000..403db1f08ac6 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/lib/web_view.dart @@ -0,0 +1,619 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart'; + +import 'navigation_decision.dart'; +import 'navigation_request.dart'; + +/// Optional callback invoked when a web view is first created. [controller] is +/// the [WebViewController] for the created web view. +typedef void WebViewCreatedCallback(WebViewController controller); + +/// Decides how to handle a specific navigation request. +/// +/// The returned [NavigationDecision] determines how the navigation described by +/// `navigation` should be handled. +/// +/// See also: [WebView.navigationDelegate]. +typedef FutureOr NavigationDelegate( + NavigationRequest navigation); + +/// Signature for when a [WebView] has started loading a page. +typedef void PageStartedCallback(String url); + +/// Signature for when a [WebView] has finished loading a page. +typedef void PageFinishedCallback(String url); + +/// Signature for when a [WebView] is loading a page. +typedef void PageLoadingCallback(int progress); + +/// Signature for when a [WebView] has failed to load a resource. +typedef void WebResourceErrorCallback(WebResourceError error); + +/// A web view widget for showing html content. +/// +/// There is a known issue that on iOS 13.4 and 13.5, other flutter widgets covering +/// the `WebView` is not able to block the `WebView` from receiving touch events. +/// See https://github.com/flutter/flutter/issues/53490. +class WebView extends StatefulWidget { + /// Creates a new web view. + /// + /// The web view can be controlled using a `WebViewController` that is passed to the + /// `onWebViewCreated` callback once the web view is created. + /// + /// The `javascriptMode` and `autoMediaPlaybackPolicy` parameters must not be null. + const WebView({ + Key? key, + this.onWebViewCreated, + this.initialUrl, + this.javascriptMode = JavascriptMode.disabled, + this.javascriptChannels, + this.navigationDelegate, + this.gestureRecognizers, + this.onPageStarted, + this.onPageFinished, + this.onProgress, + this.onWebResourceError, + this.debuggingEnabled = false, + this.gestureNavigationEnabled = false, + this.userAgent, + this.zoomEnabled = true, + this.initialMediaPlaybackPolicy = + AutoMediaPlaybackPolicy.require_user_action_for_all_media_types, + this.allowsInlineMediaPlayback = false, + }) : assert(javascriptMode != null), + assert(initialMediaPlaybackPolicy != null), + assert(allowsInlineMediaPlayback != null), + super(key: key); + + /// The WebView platform that's used by this WebView. + static final WebViewPlatform platform = CupertinoWebView(); + + /// If not null invoked once the web view is created. + final WebViewCreatedCallback? onWebViewCreated; + + /// Which gestures should be consumed by the web view. + /// + /// It is possible for other gesture recognizers to be competing with the web view on pointer + /// events, e.g if the web view is inside a [ListView] the [ListView] will want to handle + /// vertical drags. The web view will claim gestures that are recognized by any of the + /// recognizers on this list. + /// + /// When this set is empty or null, the web view will only handle pointer events for gestures that + /// were not claimed by any other gesture recognizer. + final Set>? gestureRecognizers; + + /// The initial URL to load. + final String? initialUrl; + + /// Whether JavaScript execution is enabled. + final JavascriptMode javascriptMode; + + /// The set of [JavascriptChannel]s available to JavaScript code running in the web view. + /// + /// For each [JavascriptChannel] in the set, a channel object is made available for the + /// JavaScript code in a window property named [JavascriptChannel.name]. + /// The JavaScript code can then call `postMessage` on that object to send a message that will be + /// passed to [JavascriptChannel.onMessageReceived]. + /// + /// For example for the following JavascriptChannel: + /// + /// ```dart + /// JavascriptChannel(name: 'Print', onMessageReceived: (JavascriptMessage message) { print(message.message); }); + /// ``` + /// + /// JavaScript code can call: + /// + /// ```javascript + /// Print.postMessage('Hello'); + /// ``` + /// + /// To asynchronously invoke the message handler which will print the message to standard output. + /// + /// Adding a new JavaScript channel only takes affect after the next page is loaded. + /// + /// Set values must not be null. A [JavascriptChannel.name] cannot be the same for multiple + /// channels in the list. + /// + /// A null value is equivalent to an empty set. + final Set? javascriptChannels; + + /// A delegate function that decides how to handle navigation actions. + /// + /// When a navigation is initiated by the WebView (e.g when a user clicks a link) + /// this delegate is called and has to decide how to proceed with the navigation. + /// + /// See [NavigationDecision] for possible decisions the delegate can take. + /// + /// When null all navigation actions are allowed. + /// + /// Caveats on Android: + /// + /// * Navigation actions targeted to the main frame can be intercepted, + /// navigation actions targeted to subframes are allowed regardless of the value + /// returned by this delegate. + /// * Setting a navigationDelegate makes the WebView treat all navigations as if they were + /// triggered by a user gesture, this disables some of Chromium's security mechanisms. + /// A navigationDelegate should only be set when loading trusted content. + /// * On Android WebView versions earlier than 67(most devices running at least Android L+ should have + /// a later version): + /// * When a navigationDelegate is set pages with frames are not properly handled by the + /// webview, and frames will be opened in the main frame. + /// * When a navigationDelegate is set HTTP requests do not include the HTTP referer header. + final NavigationDelegate? navigationDelegate; + + /// Controls whether inline playback of HTML5 videos is allowed on iOS. + /// + /// This field is ignored on Android because Android allows it by default. + /// + /// By default `allowsInlineMediaPlayback` is false. + final bool allowsInlineMediaPlayback; + + /// Invoked when a page starts loading. + final PageStartedCallback? onPageStarted; + + /// Invoked when a page has finished loading. + /// + /// This is invoked only for the main frame. + /// + /// When [onPageFinished] is invoked on Android, the page being rendered may + /// not be updated yet. + /// + /// When invoked on iOS or Android, any JavaScript code that is embedded + /// directly in the HTML has been loaded and code injected with + /// [WebViewController.evaluateJavascript] can assume this. + final PageFinishedCallback? onPageFinished; + + /// Invoked when a page is loading. + final PageLoadingCallback? onProgress; + + /// Invoked when a web resource has failed to load. + /// + /// This callback is only called for the main page. + final WebResourceErrorCallback? onWebResourceError; + + /// Controls whether WebView debugging is enabled. + /// + /// Setting this to true enables [WebView debugging on Android](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/). + /// + /// WebView debugging is enabled by default in dev builds on iOS. + /// + /// To debug WebViews on iOS: + /// - Enable developer options (Open Safari, go to Preferences -> Advanced and make sure "Show Develop Menu in Menubar" is on.) + /// - From the Menu-bar (of Safari) select Develop -> iPhone Simulator -> + /// + /// By default `debuggingEnabled` is false. + final bool debuggingEnabled; + + /// A Boolean value indicating whether horizontal swipe gestures will trigger back-forward list navigations. + /// + /// This only works on iOS. + /// + /// By default `gestureNavigationEnabled` is false. + final bool gestureNavigationEnabled; + + /// The value used for the HTTP User-Agent: request header. + /// + /// When null the platform's webview default is used for the User-Agent header. + /// + /// When the [WebView] is rebuilt with a different `userAgent`, the page reloads and the request uses the new User Agent. + /// + /// When [WebViewController.goBack] is called after changing `userAgent` the previous `userAgent` value is used until the page is reloaded. + /// + /// This field is ignored on iOS versions prior to 9 as the platform does not support a custom + /// user agent. + /// + /// By default `userAgent` is null. + final String? userAgent; + + /// A Boolean value indicating whether the WebView should support zooming using its on-screen zoom controls and gestures. + /// + /// By default 'zoomEnabled' is true + final bool zoomEnabled; + + /// Which restrictions apply on automatic media playback. + /// + /// This initial value is applied to the platform's webview upon creation. Any following + /// changes to this parameter are ignored (as long as the state of the [WebView] is preserved). + /// + /// The default policy is [AutoMediaPlaybackPolicy.require_user_action_for_all_media_types]. + final AutoMediaPlaybackPolicy initialMediaPlaybackPolicy; + + @override + _WebViewState createState() => _WebViewState(); +} + +class _WebViewState extends State { + final Completer _controller = + Completer(); + late final JavascriptChannelRegistry _javascriptChannelRegistry; + late final _PlatformCallbacksHandler _platformCallbacksHandler; + + @override + void initState() { + super.initState(); + _platformCallbacksHandler = _PlatformCallbacksHandler(widget); + _javascriptChannelRegistry = + JavascriptChannelRegistry(widget.javascriptChannels); + } + + @override + void didUpdateWidget(WebView oldWidget) { + super.didUpdateWidget(oldWidget); + _controller.future.then((WebViewController controller) { + controller._updateWidget(widget); + }); + } + + @override + Widget build(BuildContext context) { + return WebView.platform.build( + context: context, + onWebViewPlatformCreated: + (WebViewPlatformController? webViewPlatformController) { + WebViewController controller = WebViewController._( + widget, + webViewPlatformController!, + _javascriptChannelRegistry, + ); + _controller.complete(controller); + + if (widget.onWebViewCreated != null) { + widget.onWebViewCreated!(controller); + } + }, + webViewPlatformCallbacksHandler: _platformCallbacksHandler, + creationParams: CreationParams( + initialUrl: widget.initialUrl, + webSettings: _webSettingsFromWidget(widget), + javascriptChannelNames: + _javascriptChannelRegistry.channels.keys.toSet(), + autoMediaPlaybackPolicy: widget.initialMediaPlaybackPolicy, + userAgent: widget.userAgent, + ), + javascriptChannelRegistry: _javascriptChannelRegistry, + ); + } +} + +/// Controls a [WebView]. +/// +/// A [WebViewController] instance can be obtained by setting the [WebView.onWebViewCreated] +/// callback for a [WebView] widget. +class WebViewController { + WebViewController._( + this._widget, + this._webViewPlatformController, + this._javascriptChannelRegistry, + ) : assert(_webViewPlatformController != null) { + _settings = _webSettingsFromWidget(_widget); + } + + final JavascriptChannelRegistry _javascriptChannelRegistry; + + final WebViewPlatformController _webViewPlatformController; + + late WebSettings _settings; + + WebView _widget; + + /// Loads the specified URL. + /// + /// If `headers` is not null and the URL is an HTTP URL, the key value paris in `headers` will + /// be added as key value pairs of HTTP headers for the request. + /// + /// `url` must not be null. + /// + /// Throws an ArgumentError if `url` is not a valid URL string. + Future loadUrl( + String url, { + Map? headers, + }) async { + assert(url != null); + _validateUrlString(url); + return _webViewPlatformController.loadUrl(url, headers); + } + + /// Accessor to the current URL that the WebView is displaying. + /// + /// If [WebView.initialUrl] was never specified, returns `null`. + /// Note that this operation is asynchronous, and it is possible that the + /// current URL changes again by the time this function returns (in other + /// words, by the time this future completes, the WebView may be displaying a + /// different URL). + Future currentUrl() { + return _webViewPlatformController.currentUrl(); + } + + /// Checks whether there's a back history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoBack" state has + /// changed by the time the future completed. + Future canGoBack() { + return _webViewPlatformController.canGoBack(); + } + + /// Checks whether there's a forward history item. + /// + /// Note that this operation is asynchronous, and it is possible that the "canGoForward" state has + /// changed by the time the future completed. + Future canGoForward() { + return _webViewPlatformController.canGoForward(); + } + + /// Goes back in the history of this WebView. + /// + /// If there is no back history item this is a no-op. + Future goBack() { + return _webViewPlatformController.goBack(); + } + + /// Goes forward in the history of this WebView. + /// + /// If there is no forward history item this is a no-op. + Future goForward() { + return _webViewPlatformController.goForward(); + } + + /// Reloads the current URL. + Future reload() { + return _webViewPlatformController.reload(); + } + + /// Clears all caches used by the [WebView]. + /// + /// The following caches are cleared: + /// 1. Browser HTTP Cache. + /// 2. [Cache API](https://developers.google.com/web/fundamentals/instant-and-offline/web-storage/cache-api) caches. + /// These are not yet supported in iOS WkWebView. Service workers tend to use this cache. + /// 3. Application cache. + /// 4. Local Storage. + /// + /// Note: Calling this method also triggers a reload. + Future clearCache() async { + await _webViewPlatformController.clearCache(); + return reload(); + } + + Future _updateWidget(WebView widget) async { + _widget = widget; + await _updateSettings(_webSettingsFromWidget(widget)); + await _updateJavascriptChannels( + _javascriptChannelRegistry.channels.values.toSet()); + } + + Future _updateSettings(WebSettings newSettings) { + final WebSettings update = + _clearUnchangedWebSettings(_settings, newSettings); + _settings = newSettings; + return _webViewPlatformController.updateSettings(update); + } + + Future _updateJavascriptChannels( + Set? newChannels) async { + final Set currentChannels = + _javascriptChannelRegistry.channels.keys.toSet(); + final Set newChannelNames = _extractChannelNames(newChannels); + final Set channelsToAdd = + newChannelNames.difference(currentChannels); + final Set channelsToRemove = + currentChannels.difference(newChannelNames); + if (channelsToRemove.isNotEmpty) { + await _webViewPlatformController + .removeJavascriptChannels(channelsToRemove); + } + if (channelsToAdd.isNotEmpty) { + await _webViewPlatformController.addJavascriptChannels(channelsToAdd); + } + _javascriptChannelRegistry.updateJavascriptChannelsFromSet(newChannels); + } + + @visibleForTesting + // ignore: public_member_api_docs + Future evaluateJavascript(String javascriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'JavaScript mode must be enabled/unrestricted when calling evaluateJavascript.')); + } + return _webViewPlatformController.evaluateJavascript(javascriptString); + } + + /// Runs the given JavaScript in the context of the current page. + /// If you are looking for the result, use [runJavascriptReturningResult] instead. + /// The Future completes with an error if a JavaScript error occurred. + /// + /// When running JavaScript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the JavaScript + /// embedded in the main frame HTML has been loaded. + Future runJavascript(String javaScriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'Javascript mode must be enabled/unrestricted when calling runJavascript.')); + } + return _webViewPlatformController.runJavascript(javaScriptString); + } + + /// Runs the given JavaScript in the context of the current page, and returns the result. + /// + /// Depending on the value type the return value would be one of: + /// - For primitive JavaScript types: the value string formatted (e.g JavaScript 100 returns '100'). + /// - For JavaScript arrays of supported types: a string formatted NSArray(e.g '(1,2,3), note that the string for NSArray is formatted and might contain newlines and extra spaces.'). + /// + /// The Future completes with an error if a JavaScript error occurred, or if the + /// type the given expression evaluates to is unsupported. Unsupported values include + /// certain non primitive types, as well as `undefined` or `null` on iOS 14+. + /// + /// When evaluating JavaScript in a [WebView], it is best practice to wait for + /// the [WebView.onPageFinished] callback. This guarantees all the JavaScript + /// embedded in the main frame HTML has been loaded. + Future runJavascriptReturningResult(String javaScriptString) { + if (_settings.javascriptMode == JavascriptMode.disabled) { + return Future.error(FlutterError( + 'Javascript mode must be enabled/unrestricted when calling runJavascriptReturningResult.')); + } + return _webViewPlatformController + .runJavascriptReturningResult(javaScriptString); + } + + /// Returns the title of the currently loaded page. + Future getTitle() { + return _webViewPlatformController.getTitle(); + } + + /// Sets the WebView's content scroll position. + /// + /// The parameters `x` and `y` specify the scroll position in WebView pixels. + Future scrollTo(int x, int y) { + return _webViewPlatformController.scrollTo(x, y); + } + + /// Move the scrolled position of this view. + /// + /// The parameters `x` and `y` specify the amount of WebView pixels to scroll by horizontally and vertically respectively. + Future scrollBy(int x, int y) { + return _webViewPlatformController.scrollBy(x, y); + } + + /// Return the horizontal scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from left. + Future getScrollX() { + return _webViewPlatformController.getScrollX(); + } + + /// Return the vertical scroll position, in WebView pixels, of this view. + /// + /// Scroll position is measured from top. + Future getScrollY() { + return _webViewPlatformController.getScrollY(); + } + + // This method assumes that no fields in `currentValue` are null. + WebSettings _clearUnchangedWebSettings( + WebSettings currentValue, WebSettings newValue) { + assert(currentValue.javascriptMode != null); + assert(currentValue.hasNavigationDelegate != null); + assert(currentValue.hasProgressTracking != null); + assert(currentValue.debuggingEnabled != null); + assert(currentValue.userAgent != null); + assert(newValue.javascriptMode != null); + assert(newValue.hasNavigationDelegate != null); + assert(newValue.debuggingEnabled != null); + assert(newValue.userAgent != null); + + JavascriptMode? javascriptMode; + bool? hasNavigationDelegate; + bool? hasProgressTracking; + bool? debuggingEnabled; + WebSetting userAgent = WebSetting.absent(); + if (currentValue.javascriptMode != newValue.javascriptMode) { + javascriptMode = newValue.javascriptMode; + } + if (currentValue.hasNavigationDelegate != newValue.hasNavigationDelegate) { + hasNavigationDelegate = newValue.hasNavigationDelegate; + } + if (currentValue.hasProgressTracking != newValue.hasProgressTracking) { + hasProgressTracking = newValue.hasProgressTracking; + } + if (currentValue.debuggingEnabled != newValue.debuggingEnabled) { + debuggingEnabled = newValue.debuggingEnabled; + } + if (currentValue.userAgent != newValue.userAgent) { + userAgent = newValue.userAgent; + } + + return WebSettings( + javascriptMode: javascriptMode, + hasNavigationDelegate: hasNavigationDelegate, + hasProgressTracking: hasProgressTracking, + debuggingEnabled: debuggingEnabled, + userAgent: userAgent, + ); + } + + Set _extractChannelNames(Set? channels) { + final Set channelNames = channels == null + ? {} + : channels.map((JavascriptChannel channel) => channel.name).toSet(); + return channelNames; + } + +// Throws an ArgumentError if `url` is not a valid URL string. + void _validateUrlString(String url) { + try { + final Uri uri = Uri.parse(url); + if (uri.scheme.isEmpty) { + throw ArgumentError('Missing scheme in URL string: "$url"'); + } + } on FormatException catch (e) { + throw ArgumentError(e); + } + } +} + +WebSettings _webSettingsFromWidget(WebView widget) { + return WebSettings( + javascriptMode: widget.javascriptMode, + hasNavigationDelegate: widget.navigationDelegate != null, + hasProgressTracking: widget.onProgress != null, + debuggingEnabled: widget.debuggingEnabled, + gestureNavigationEnabled: widget.gestureNavigationEnabled, + allowsInlineMediaPlayback: widget.allowsInlineMediaPlayback, + userAgent: WebSetting.of(widget.userAgent), + zoomEnabled: widget.zoomEnabled, + ); +} + +class _PlatformCallbacksHandler implements WebViewPlatformCallbacksHandler { + _PlatformCallbacksHandler(this._webView); + + final WebView _webView; + + @override + FutureOr onNavigationRequest({ + required String url, + required bool isForMainFrame, + }) async { + if (url.startsWith('https://www.youtube.com/')) { + print('blocking navigation to $url'); + return false; + } + print('allowing navigation to $url'); + return true; + } + + @override + void onPageStarted(String url) { + if (_webView.onPageStarted != null) { + _webView.onPageStarted!(url); + } + } + + @override + void onPageFinished(String url) { + if (_webView.onPageFinished != null) { + _webView.onPageFinished!(url); + } + } + + @override + void onProgress(int progress) { + if (_webView.onProgress != null) { + _webView.onProgress!(progress); + } + } + + void onWebResourceError(WebResourceError error) { + if (_webView.onWebResourceError != null) { + _webView.onWebResourceError!(error); + } + } +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/pubspec.yaml b/packages/webview_flutter/webview_flutter_wkwebview/example/pubspec.yaml new file mode 100644 index 000000000000..229da5e337a5 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/pubspec.yaml @@ -0,0 +1,33 @@ +name: webview_flutter_wkwebview_example +description: Demonstrates how to use the webview_flutter_wkwebview plugin. +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + webview_flutter_wkwebview: + # When depending on this package from a real application you should use: + # webview_flutter: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + espresso: ^0.1.0+2 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + integration_test: + sdk: flutter + pedantic: ^1.10.0 + +flutter: + uses-material-design: true + assets: + - assets/sample_audio.ogg + - assets/sample_video.mp4 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/example/test_driver/integration_test.dart b/packages/webview_flutter/webview_flutter_wkwebview/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Assets/.gitkeep b/packages/webview_flutter/webview_flutter_wkwebview/ios/Assets/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.h new file mode 100644 index 000000000000..8fe331875250 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.h @@ -0,0 +1,14 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTCookieManager : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.m new file mode 100644 index 000000000000..f4783ffb4123 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTCookieManager.m @@ -0,0 +1,49 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTCookieManager.h" + +@implementation FLTCookieManager { +} + ++ (void)registerWithRegistrar:(NSObject *)registrar { + FLTCookieManager *instance = [[FLTCookieManager alloc] init]; + + FlutterMethodChannel *channel = + [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/cookie_manager" + binaryMessenger:[registrar messenger]]; + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if ([[call method] isEqualToString:@"clearCookies"]) { + [self clearCookies:result]; + } else { + result(FlutterMethodNotImplemented); + } +} + +- (void)clearCookies:(FlutterResult)result { + if (@available(iOS 9.0, *)) { + NSSet *websiteDataTypes = [NSSet setWithObject:WKWebsiteDataTypeCookies]; + WKWebsiteDataStore *dataStore = [WKWebsiteDataStore defaultDataStore]; + + void (^deleteAndNotify)(NSArray *) = + ^(NSArray *cookies) { + BOOL hasCookies = cookies.count > 0; + [dataStore removeDataOfTypes:websiteDataTypes + forDataRecords:cookies + completionHandler:^{ + result(@(hasCookies)); + }]; + }; + + [dataStore fetchDataRecordsOfTypes:websiteDataTypes completionHandler:deleteAndNotify]; + } else { + // support for iOS8 tracked in https://github.com/flutter/flutter/issues/27624. + NSLog(@"Clearing cookies is not supported for Flutter WebViews prior to iOS 9."); + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.h new file mode 100644 index 000000000000..6531931c4cf4 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.h @@ -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 +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTWKNavigationDelegate : NSObject + +- (instancetype)initWithChannel:(FlutterMethodChannel *)channel; + +/** + * Whether to delegate navigation decisions over the method channel. + */ +@property(nonatomic, assign) BOOL hasDartNavigationDelegate; + +/** + * Whether to allow zoom functionality on the WebView. + */ +@property(nonatomic, assign) BOOL shouldEnableZoom; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.m new file mode 100644 index 000000000000..125d3cabdcf1 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKNavigationDelegate.m @@ -0,0 +1,127 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTWKNavigationDelegate.h" + +@implementation FLTWKNavigationDelegate { + FlutterMethodChannel *_methodChannel; +} + +- (instancetype)initWithChannel:(FlutterMethodChannel *)channel { + self = [super init]; + if (self) { + _methodChannel = channel; + } + return self; +} + +#pragma mark - WKNavigationDelegate conformance + +- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation { + [_methodChannel invokeMethod:@"onPageStarted" arguments:@{@"url" : webView.URL.absoluteString}]; +} + +- (void)webView:(WKWebView *)webView + decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction + decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { + if (!self.hasDartNavigationDelegate) { + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + NSDictionary *arguments = @{ + @"url" : navigationAction.request.URL.absoluteString, + @"isForMainFrame" : @(navigationAction.targetFrame.isMainFrame) + }; + [_methodChannel invokeMethod:@"navigationRequest" + arguments:arguments + result:^(id _Nullable result) { + if ([result isKindOfClass:[FlutterError class]]) { + NSLog(@"navigationRequest has unexpectedly completed with an error, " + @"allowing navigation."); + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + if (result == FlutterMethodNotImplemented) { + NSLog(@"navigationRequest was unexepectedly not implemented: %@, " + @"allowing navigation.", + result); + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + if (![result isKindOfClass:[NSNumber class]]) { + NSLog(@"navigationRequest unexpectedly returned a non boolean value: " + @"%@, allowing navigation.", + result); + decisionHandler(WKNavigationActionPolicyAllow); + return; + } + NSNumber *typedResult = result; + decisionHandler([typedResult boolValue] ? WKNavigationActionPolicyAllow + : WKNavigationActionPolicyCancel); + }]; +} + +- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { + if (!self.shouldEnableZoom) { + NSString *source = + @"var meta = document.createElement('meta');" + @"meta.name = 'viewport';" + @"meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0," + @"user-scalable=no';" + @"var head = document.getElementsByTagName('head')[0];head.appendChild(meta);"; + + [webView evaluateJavaScript:source completionHandler:nil]; + } + + [_methodChannel invokeMethod:@"onPageFinished" arguments:@{@"url" : webView.URL.absoluteString}]; +} + ++ (id)errorCodeToString:(NSUInteger)code { + switch (code) { + case WKErrorUnknown: + return @"unknown"; + case WKErrorWebContentProcessTerminated: + return @"webContentProcessTerminated"; + case WKErrorWebViewInvalidated: + return @"webViewInvalidated"; + case WKErrorJavaScriptExceptionOccurred: + return @"javaScriptExceptionOccurred"; + case WKErrorJavaScriptResultTypeIsUnsupported: + return @"javaScriptResultTypeIsUnsupported"; + } + + return [NSNull null]; +} + +- (void)onWebResourceError:(NSError *)error { + [_methodChannel invokeMethod:@"onWebResourceError" + arguments:@{ + @"errorCode" : @(error.code), + @"domain" : error.domain, + @"description" : error.description, + @"errorType" : [FLTWKNavigationDelegate errorCodeToString:error.code], + }]; +} + +- (void)webView:(WKWebView *)webView + didFailNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + [self onWebResourceError:error]; +} + +- (void)webView:(WKWebView *)webView + didFailProvisionalNavigation:(WKNavigation *)navigation + withError:(NSError *)error { + [self onWebResourceError:error]; +} + +- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView { + NSError *contentProcessTerminatedError = + [[NSError alloc] initWithDomain:WKErrorDomain + code:WKErrorWebContentProcessTerminated + userInfo:nil]; + [self onWebResourceError:contentProcessTerminatedError]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.h new file mode 100644 index 000000000000..96af4ef6c578 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.h @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTWKProgressionDelegate : NSObject + +- (instancetype)initWithWebView:(WKWebView *)webView channel:(FlutterMethodChannel *)channel; + +- (void)stopObservingProgress:(WKWebView *)webView; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.m new file mode 100644 index 000000000000..8e7af4649aa0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWKProgressionDelegate.m @@ -0,0 +1,41 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTWKProgressionDelegate.h" + +NSString *const FLTWKEstimatedProgressKeyPath = @"estimatedProgress"; + +@implementation FLTWKProgressionDelegate { + FlutterMethodChannel *_methodChannel; +} + +- (instancetype)initWithWebView:(WKWebView *)webView channel:(FlutterMethodChannel *)channel { + self = [super init]; + if (self) { + _methodChannel = channel; + [webView addObserver:self + forKeyPath:FLTWKEstimatedProgressKeyPath + options:NSKeyValueObservingOptionNew + context:nil]; + } + return self; +} + +- (void)stopObservingProgress:(WKWebView *)webView { + [webView removeObserver:self forKeyPath:FLTWKEstimatedProgressKeyPath]; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context { + if ([keyPath isEqualToString:FLTWKEstimatedProgressKeyPath]) { + NSNumber *newValue = + change[NSKeyValueChangeNewKey] ?: 0; // newValue is anywhere between 0.0 and 1.0 + int newValueAsInt = [newValue floatValue] * 100; // Anywhere between 0 and 100 + [_methodChannel invokeMethod:@"onProgress" arguments:@{@"progress" : @(newValueAsInt)}]; + } +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.h new file mode 100644 index 000000000000..2a80c7d886f2 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.h @@ -0,0 +1,8 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +@interface FLTWebViewFlutterPlugin : NSObject +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.m new file mode 100644 index 000000000000..76ab290a2fbe --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FLTWebViewFlutterPlugin.m @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTWebViewFlutterPlugin.h" +#import "FLTCookieManager.h" +#import "FlutterWebView.h" + +@implementation FLTWebViewFlutterPlugin + ++ (void)registerWithRegistrar:(NSObject*)registrar { + FLTWebViewFactory* webviewFactory = [[FLTWebViewFactory alloc] initWithRegistrar:registrar]; + [registrar registerViewFactory:webviewFactory withId:@"plugins.flutter.io/webview"]; + [FLTCookieManager registerWithRegistrar:registrar]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.h new file mode 100644 index 000000000000..c84e1064a354 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.h @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * The WkWebView used for the plugin. + * + * This class overrides some methods in `WKWebView` to serve the needs for the plugin. + */ +@interface FLTWKWebView : WKWebView +@end + +@interface FLTWebViewController : NSObject + +@property(nonatomic) FLTWKWebView* webView; + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + registrar:(NSObject*)registrar; + +- (UIView*)view; + +- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; +@end + +@interface FLTWebViewFactory : NSObject +- (instancetype)initWithRegistrar:(NSObject*)registrar; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.m new file mode 100644 index 000000000000..85d83a7e66b2 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/FlutterWebView.m @@ -0,0 +1,617 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FlutterWebView.h" +#import "FLTWKNavigationDelegate.h" +#import "FLTWKProgressionDelegate.h" +#import "JavaScriptChannelHandler.h" + +@implementation FLTWebViewFactory { + NSObject* _messenger; + NSObject* _registrar; +} + +- (instancetype)initWithRegistrar:(NSObject*)registrar { + self = [super init]; + if (self) { + _registrar = registrar; + _messenger = registrar.messenger; + } + return self; +} + +- (NSObject*)createArgsCodec { + return [FlutterStandardMessageCodec sharedInstance]; +} + +- (NSObject*)createWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args { + FLTWebViewController* webviewController = [[FLTWebViewController alloc] initWithFrame:frame + viewIdentifier:viewId + arguments:args + registrar:_registrar]; + return webviewController; +} + +@end + +@implementation FLTWKWebView + +- (void)setFrame:(CGRect)frame { + [super setFrame:frame]; + self.scrollView.contentInset = UIEdgeInsetsZero; + // We don't want the contentInsets to be adjusted by iOS, flutter should always take control of + // webview's contentInsets. + // self.scrollView.contentInset = UIEdgeInsetsZero; + if (@available(iOS 11, *)) { + // Above iOS 11, adjust contentInset to compensate the adjustedContentInset so the sum will + // always be 0. + if (UIEdgeInsetsEqualToEdgeInsets(self.scrollView.adjustedContentInset, UIEdgeInsetsZero)) { + return; + } + UIEdgeInsets insetToAdjust = self.scrollView.adjustedContentInset; + self.scrollView.contentInset = UIEdgeInsetsMake(-insetToAdjust.top, -insetToAdjust.left, + -insetToAdjust.bottom, -insetToAdjust.right); + } +} + +@end + +@implementation FLTWebViewController { + FLTWKWebView* _webView; + int64_t _viewId; + FlutterMethodChannel* _channel; + NSString* _currentUrl; + // The set of registered JavaScript channel names. + NSMutableSet* _javaScriptChannelNames; + FLTWKNavigationDelegate* _navigationDelegate; + NSObject* _registrar; + FLTWKProgressionDelegate* _progressionDelegate; +} + +- (instancetype)initWithFrame:(CGRect)frame + viewIdentifier:(int64_t)viewId + arguments:(id _Nullable)args + registrar:(nonnull NSObject*)registrar { + if (self = [super init]) { + _viewId = viewId; + + _registrar = registrar; + NSString* channelName = [NSString stringWithFormat:@"plugins.flutter.io/webview_%lld", viewId]; + _channel = [FlutterMethodChannel methodChannelWithName:channelName + binaryMessenger:_registrar.messenger]; + _javaScriptChannelNames = [[NSMutableSet alloc] init]; + + WKUserContentController* userContentController = [[WKUserContentController alloc] init]; + if ([args[@"javascriptChannelNames"] isKindOfClass:[NSArray class]]) { + NSArray* javaScriptChannelNames = args[@"javascriptChannelNames"]; + [_javaScriptChannelNames addObjectsFromArray:javaScriptChannelNames]; + [self registerJavaScriptChannels:_javaScriptChannelNames controller:userContentController]; + } + + NSDictionary* settings = args[@"settings"]; + + WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init]; + [self applyConfigurationSettings:settings toConfiguration:configuration]; + configuration.userContentController = userContentController; + [self updateAutoMediaPlaybackPolicy:args[@"autoMediaPlaybackPolicy"] + inConfiguration:configuration]; + + _webView = [[FLTWKWebView alloc] initWithFrame:frame configuration:configuration]; + _navigationDelegate = [[FLTWKNavigationDelegate alloc] initWithChannel:_channel]; + _webView.UIDelegate = self; + _webView.navigationDelegate = _navigationDelegate; + __weak __typeof__(self) weakSelf = self; + [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) { + [weakSelf onMethodCall:call result:result]; + }]; + + if (@available(iOS 11.0, *)) { + _webView.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + if (@available(iOS 13.0, *)) { + _webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = NO; + } + } + + [self applySettings:settings]; + // TODO(amirh): return an error if apply settings failed once it's possible to do so. + // https://github.com/flutter/flutter/issues/36228 + + NSString* initialUrl = args[@"initialUrl"]; + if ([initialUrl isKindOfClass:[NSString class]]) { + [self loadUrl:initialUrl]; + } + } + return self; +} + +- (void)dealloc { + if (_progressionDelegate != nil) { + [_progressionDelegate stopObservingProgress:_webView]; + } +} + +- (UIView*)view { + return _webView; +} + +- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if ([[call method] isEqualToString:@"updateSettings"]) { + [self onUpdateSettings:call result:result]; + } else if ([[call method] isEqualToString:@"loadUrl"]) { + [self onLoadUrl:call result:result]; + } else if ([[call method] isEqualToString:@"loadAssetHtmlFile"]) { + [self onLoadAssetHtmlFile:call result:result]; + } else if ([[call method] isEqualToString:@"loadLocalHtmlFile"]) { + [self onLoadLocalHtmlFile:call result:result]; + } else if ([[call method] isEqualToString:@"canGoBack"]) { + [self onCanGoBack:call result:result]; + } else if ([[call method] isEqualToString:@"canGoForward"]) { + [self onCanGoForward:call result:result]; + } else if ([[call method] isEqualToString:@"goBack"]) { + [self onGoBack:call result:result]; + } else if ([[call method] isEqualToString:@"goForward"]) { + [self onGoForward:call result:result]; + } else if ([[call method] isEqualToString:@"reload"]) { + [self onReload:call result:result]; + } else if ([[call method] isEqualToString:@"currentUrl"]) { + [self onCurrentUrl:call result:result]; + } else if ([[call method] isEqualToString:@"evaluateJavascript"]) { + [self onEvaluateJavaScript:call result:result]; + } else if ([[call method] isEqualToString:@"runJavascript"]) { + [self onRunJavaScript:call result:result sendReturnValue:NO]; + } else if ([[call method] isEqualToString:@"runJavascriptReturningResult"]) { + [self onRunJavaScript:call result:result sendReturnValue:YES]; + } else if ([[call method] isEqualToString:@"addJavascriptChannels"]) { + [self onAddJavaScriptChannels:call result:result]; + } else if ([[call method] isEqualToString:@"removeJavascriptChannels"]) { + [self onRemoveJavaScriptChannels:call result:result]; + } else if ([[call method] isEqualToString:@"clearCache"]) { + [self clearCache:result]; + } else if ([[call method] isEqualToString:@"getTitle"]) { + [self onGetTitle:result]; + } else if ([[call method] isEqualToString:@"scrollTo"]) { + [self onScrollTo:call result:result]; + } else if ([[call method] isEqualToString:@"scrollBy"]) { + [self onScrollBy:call result:result]; + } else if ([[call method] isEqualToString:@"getScrollX"]) { + [self getScrollX:call result:result]; + } else if ([[call method] isEqualToString:@"getScrollY"]) { + [self getScrollY:call result:result]; + } else { + result(FlutterMethodNotImplemented); + } +} + +- (void)onUpdateSettings:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* error = [self applySettings:[call arguments]]; + if (error == nil) { + result(nil); + return; + } + result([FlutterError errorWithCode:@"updateSettings_failed" message:error details:nil]); +} + +- (void)onLoadUrl:(FlutterMethodCall*)call result:(FlutterResult)result { + if (![self loadRequest:[call arguments]]) { + result([FlutterError + errorWithCode:@"loadUrl_failed" + message:@"Failed parsing the URL" + details:[NSString stringWithFormat:@"Request was: '%@'", [call arguments]]]); + } else { + result(nil); + } +} + +- (void)onLoadAssetHtmlFile:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* url = [call arguments]; + if (![self loadAssetHtmlFile:url]) { + result([FlutterError errorWithCode:@"loadAssetHtmlFile_failed" + message:@"Failed parsing the URL" + details:[NSString stringWithFormat:@"URL was: '%@'", url]]); + } else { + result(nil); + } +} + +- (void)onLoadLocalHtmlFile:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* url = [call arguments]; + if (![self loadLocalHtmlFile:url]) { + result([FlutterError errorWithCode:@"loadAssetHtmlFile_failed" + message:@"Failed parsing the URL" + details:[NSString stringWithFormat:@"URL was: '%@'", url]]); + } else { + result(nil); + } +} + + +- (void)onCanGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { + BOOL canGoBack = [_webView canGoBack]; + result(@(canGoBack)); +} + +- (void)onCanGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { + BOOL canGoForward = [_webView canGoForward]; + result(@(canGoForward)); +} + +- (void)onGoBack:(FlutterMethodCall*)call result:(FlutterResult)result { + [_webView goBack]; + result(nil); +} + +- (void)onGoForward:(FlutterMethodCall*)call result:(FlutterResult)result { + [_webView goForward]; + result(nil); +} + +- (void)onReload:(FlutterMethodCall*)call result:(FlutterResult)result { + [_webView reload]; + result(nil); +} + +- (void)onCurrentUrl:(FlutterMethodCall*)call result:(FlutterResult)result { + _currentUrl = [[_webView URL] absoluteString]; + result(_currentUrl); +} + +- (void)onEvaluateJavaScript:(FlutterMethodCall*)call result:(FlutterResult)result { + NSString* jsString = [call arguments]; + if (!jsString) { + result([FlutterError errorWithCode:@"evaluateJavaScript_failed" + message:@"JavaScript String cannot be null" + details:nil]); + return; + } + [_webView evaluateJavaScript:jsString + completionHandler:^(_Nullable id evaluateResult, NSError* _Nullable error) { + if (error) { + result([FlutterError + errorWithCode:@"evaluateJavaScript_failed" + message:@"Failed evaluating JavaScript" + details:[NSString stringWithFormat:@"JavaScript string was: '%@'\n%@", + jsString, error]]); + } else { + result([NSString stringWithFormat:@"%@", evaluateResult]); + } + }]; +} + +- (void)onRunJavaScript:(FlutterMethodCall*)call + result:(FlutterResult)result + sendReturnValue:(BOOL)sendReturnValue { + NSString* jsString = [call arguments]; + if (!jsString) { + result([FlutterError errorWithCode:@"runJavascript_failed" + message:@"JavaScript String cannot be null" + details:nil]); + return; + } + [_webView + evaluateJavaScript:jsString + completionHandler:^(_Nullable id evaluateResult, NSError* _Nullable error) { + if (error) { + // WebKit will throw an error (WKErrorJavaScriptResultTypeIsUnsupported) when the + // type of the evaluated value is unsupported. This also goes for + // `null` and `undefined` on iOS 14+, for example when running a void function. + // For ease of use this specific error is ignored when no return value is expected. + BOOL sendError = + sendReturnValue || error.code != WKErrorJavaScriptResultTypeIsUnsupported; + result(sendError + ? [FlutterError + errorWithCode:(sendReturnValue ? @"runJavascriptReturningResult_failed" + : @"runJavascript_failed") + message:@"Failed running JavaScript" + details:[NSString + stringWithFormat:@"JavaScript string was: '%@'\n%@", + jsString, error]] + : nil); + return; + } + result(sendReturnValue ? [NSString stringWithFormat:@"%@", evaluateResult] : nil); + }]; +} + +- (void)onAddJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { + NSArray* channelNames = [call arguments]; + NSSet* channelNamesSet = [[NSSet alloc] initWithArray:channelNames]; + [_javaScriptChannelNames addObjectsFromArray:channelNames]; + [self registerJavaScriptChannels:channelNamesSet + controller:_webView.configuration.userContentController]; + result(nil); +} + +- (void)onRemoveJavaScriptChannels:(FlutterMethodCall*)call result:(FlutterResult)result { + // WkWebView does not support removing a single user script, so instead we remove all + // user scripts, all message handlers. And re-register channels that shouldn't be removed. + [_webView.configuration.userContentController removeAllUserScripts]; + for (NSString* channelName in _javaScriptChannelNames) { + [_webView.configuration.userContentController removeScriptMessageHandlerForName:channelName]; + } + + NSArray* channelNamesToRemove = [call arguments]; + for (NSString* channelName in channelNamesToRemove) { + [_javaScriptChannelNames removeObject:channelName]; + } + + [self registerJavaScriptChannels:_javaScriptChannelNames + controller:_webView.configuration.userContentController]; + result(nil); +} + +- (void)clearCache:(FlutterResult)result { + if (@available(iOS 9.0, *)) { + NSSet* cacheDataTypes = [WKWebsiteDataStore allWebsiteDataTypes]; + WKWebsiteDataStore* dataStore = [WKWebsiteDataStore defaultDataStore]; + NSDate* dateFrom = [NSDate dateWithTimeIntervalSince1970:0]; + [dataStore removeDataOfTypes:cacheDataTypes + modifiedSince:dateFrom + completionHandler:^{ + result(nil); + }]; + } else { + // support for iOS8 tracked in https://github.com/flutter/flutter/issues/27624. + NSLog(@"Clearing cache is not supported for Flutter WebViews prior to iOS 9."); + } +} + +- (void)onGetTitle:(FlutterResult)result { + NSString* title = _webView.title; + result(title); +} + +- (void)onScrollTo:(FlutterMethodCall*)call result:(FlutterResult)result { + NSDictionary* arguments = [call arguments]; + int x = [arguments[@"x"] intValue]; + int y = [arguments[@"y"] intValue]; + + _webView.scrollView.contentOffset = CGPointMake(x, y); + result(nil); +} + +- (void)onScrollBy:(FlutterMethodCall*)call result:(FlutterResult)result { + CGPoint contentOffset = _webView.scrollView.contentOffset; + + NSDictionary* arguments = [call arguments]; + int x = [arguments[@"x"] intValue] + contentOffset.x; + int y = [arguments[@"y"] intValue] + contentOffset.y; + + _webView.scrollView.contentOffset = CGPointMake(x, y); + result(nil); +} + +- (void)getScrollX:(FlutterMethodCall*)call result:(FlutterResult)result { + int offsetX = _webView.scrollView.contentOffset.x; + result(@(offsetX)); +} + +- (void)getScrollY:(FlutterMethodCall*)call result:(FlutterResult)result { + int offsetY = _webView.scrollView.contentOffset.y; + result(@(offsetY)); +} + +// Returns nil when successful, or an error message when one or more keys are unknown. +- (NSString*)applySettings:(NSDictionary*)settings { + NSMutableArray* unknownKeys = [[NSMutableArray alloc] init]; + for (NSString* key in settings) { + if ([key isEqualToString:@"jsMode"]) { + NSNumber* mode = settings[key]; + [self updateJsMode:mode]; + } else if ([key isEqualToString:@"hasNavigationDelegate"]) { + NSNumber* hasDartNavigationDelegate = settings[key]; + _navigationDelegate.hasDartNavigationDelegate = [hasDartNavigationDelegate boolValue]; + } else if ([key isEqualToString:@"hasProgressTracking"]) { + NSNumber* hasProgressTrackingValue = settings[key]; + bool hasProgressTracking = [hasProgressTrackingValue boolValue]; + if (hasProgressTracking) { + _progressionDelegate = [[FLTWKProgressionDelegate alloc] initWithWebView:_webView + channel:_channel]; + } + } else if ([key isEqualToString:@"debuggingEnabled"]) { + // no-op debugging is always enabled on iOS. + } else if ([key isEqualToString:@"gestureNavigationEnabled"]) { + NSNumber* allowsBackForwardNavigationGestures = settings[key]; + _webView.allowsBackForwardNavigationGestures = + [allowsBackForwardNavigationGestures boolValue]; + } else if ([key isEqualToString:@"userAgent"]) { + NSString* userAgent = settings[key]; + [self updateUserAgent:[userAgent isEqual:[NSNull null]] ? nil : userAgent]; + } else if ([key isEqualToString:@"zoomEnabled"]) { + NSNumber* zoomEnabled = settings[key]; + _navigationDelegate.shouldEnableZoom = [zoomEnabled boolValue]; + } else { + [unknownKeys addObject:key]; + } + } + if ([unknownKeys count] == 0) { + return nil; + } + return [NSString stringWithFormat:@"webview_flutter: unknown setting keys: {%@}", + [unknownKeys componentsJoinedByString:@", "]]; +} + +- (void)applyConfigurationSettings:(NSDictionary*)settings + toConfiguration:(WKWebViewConfiguration*)configuration { + NSAssert(configuration != _webView.configuration, + @"configuration needs to be updated before webView.configuration."); + for (NSString* key in settings) { + if ([key isEqualToString:@"allowsInlineMediaPlayback"]) { + NSNumber* allowsInlineMediaPlayback = settings[key]; + configuration.allowsInlineMediaPlayback = [allowsInlineMediaPlayback boolValue]; + } + } +} + +- (void)updateJsMode:(NSNumber*)mode { + WKPreferences* preferences = [[_webView configuration] preferences]; + switch ([mode integerValue]) { + case 0: // disabled + [preferences setJavaScriptEnabled:NO]; + break; + case 1: // unrestricted + [preferences setJavaScriptEnabled:YES]; + break; + default: + NSLog(@"webview_flutter: unknown JavaScript mode: %@", mode); + } +} + +- (void)updateAutoMediaPlaybackPolicy:(NSNumber*)policy + inConfiguration:(WKWebViewConfiguration*)configuration { + switch ([policy integerValue]) { + case 0: // require_user_action_for_all_media_types + if (@available(iOS 10.0, *)) { + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; + } else if (@available(iOS 9.0, *)) { + configuration.requiresUserActionForMediaPlayback = true; + } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + configuration.mediaPlaybackRequiresUserAction = true; +#pragma clang diagnostic pop + } + break; + case 1: // always_allow + if (@available(iOS 10.0, *)) { + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; + } else if (@available(iOS 9.0, *)) { + configuration.requiresUserActionForMediaPlayback = false; + } else { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + configuration.mediaPlaybackRequiresUserAction = false; +#pragma clang diagnostic pop + } + break; + default: + NSLog(@"webview_flutter: unknown auto media playback policy: %@", policy); + } +} + +- (bool)loadRequest:(NSDictionary*)request { + if (!request) { + return false; + } + + NSString* url = request[@"url"]; + if ([url isKindOfClass:[NSString class]]) { + id headers = request[@"headers"]; + if ([headers isKindOfClass:[NSDictionary class]]) { + return [self loadUrl:url withHeaders:headers]; + } else { + return [self loadUrl:url]; + } + } + + return false; +} + +- (bool)loadUrl:(NSString*)url { + return [self loadUrl:url withHeaders:[NSMutableDictionary dictionary]]; +} + +- (bool)loadUrl:(NSString*)url withHeaders:(NSDictionary*)headers { + NSURL* nsUrl = [NSURL URLWithString:url]; + if (!nsUrl) { + return false; + } + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:nsUrl]; + [request setAllHTTPHeaderFields:headers]; + [_webView loadRequest:request]; + return true; +} + +- (bool)loadAssetHtmlFile:(NSString*)url { + NSArray* array = [url componentsSeparatedByString:@"?"]; + NSString* pathString = [array objectAtIndex:0]; + NSLog(@"%@%@", @"pathString: ", pathString); + NSString* key = [_registrar lookupKeyForAsset:pathString]; + NSURL* baseURL = [[NSBundle mainBundle] URLForResource:key withExtension:nil]; + if (!baseURL) { + return false; + } + NSURL* newUrl = baseURL; + if ([array count] > 1) { + NSString* queryString = [array objectAtIndex:1]; + NSLog(@"%@%@", @"queryString: ", queryString); + NSString* queryPart = [NSString stringWithFormat:@"%@%@", @"?", queryString]; + NSLog(@"%@%@", @"queryPart: ", queryPart); + newUrl = [NSURL URLWithString:queryPart relativeToURL:baseURL]; + } + if (@available(iOS 9.0, *)) { + [_webView loadFileURL:newUrl allowingReadAccessToURL:[NSURL URLWithString:@"file:///"]]; + } else { + return false; + } + return true; +} + +- (bool)loadLocalHtmlFile:(NSString*)url { + NSArray* array = [url componentsSeparatedByString:@"?"]; + NSString* pathString = [array objectAtIndex:0]; + NSLog(@"%@%@", @"pathString: ", pathString); + NSString* key = [_registrar lookupKeyForAsset:pathString]; + NSURL* baseURL = [[NSBundle mainBundle] URLForResource:key withExtension:nil]; + if (!baseURL) { + [_webView loadFileURL:[NSURL fileURLWithPath:pathString] + allowingReadAccessToURL:[NSURL fileURLWithPath:pathString]]; + return true; + } + NSURL* newUrl = baseURL; + if ([array count] > 1) { + NSString* queryString = [array objectAtIndex:1]; + NSLog(@"%@%@", @"queryString: ", queryString); + NSString* queryPart = [NSString stringWithFormat:@"%@%@", @"?", queryString]; + NSLog(@"%@%@", @"queryPart: ", queryPart); + newUrl = [NSURL URLWithString:queryPart relativeToURL:baseURL]; + } + if (@available(iOS 9.0, *)) { + [_webView loadFileURL:newUrl allowingReadAccessToURL:[NSURL URLWithString:@"file:///"]]; + } else { + return false; + } + return true; +} + +- (void)registerJavaScriptChannels:(NSSet*)channelNames + controller:(WKUserContentController*)userContentController { + for (NSString* channelName in channelNames) { + FLTJavaScriptChannel* channel = + [[FLTJavaScriptChannel alloc] initWithMethodChannel:_channel + javaScriptChannelName:channelName]; + [userContentController addScriptMessageHandler:channel name:channelName]; + NSString* wrapperSource = [NSString + stringWithFormat:@"window.%@ = webkit.messageHandlers.%@;", channelName, channelName]; + WKUserScript* wrapperScript = + [[WKUserScript alloc] initWithSource:wrapperSource + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [userContentController addUserScript:wrapperScript]; + } +} + +- (void)updateUserAgent:(NSString*)userAgent { + if (@available(iOS 9.0, *)) { + [_webView setCustomUserAgent:userAgent]; + } else { + NSLog(@"Updating UserAgent is not supported for Flutter WebViews prior to iOS 9."); + } +} + +#pragma mark WKUIDelegate + +- (WKWebView*)webView:(WKWebView*)webView + createWebViewWithConfiguration:(WKWebViewConfiguration*)configuration + forNavigationAction:(WKNavigationAction*)navigationAction + windowFeatures:(WKWindowFeatures*)windowFeatures { + if (!navigationAction.targetFrame.isMainFrame) { + [webView loadRequest:navigationAction.request]; + } + + return nil; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.h b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.h new file mode 100644 index 000000000000..a0a5ec657295 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.h @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTJavaScriptChannel : NSObject + +- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)methodChannel + javaScriptChannelName:(NSString*)javaScriptChannelName; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.m b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.m new file mode 100644 index 000000000000..ec9a363a4b2e --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/Classes/JavaScriptChannelHandler.m @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "JavaScriptChannelHandler.h" + +@implementation FLTJavaScriptChannel { + FlutterMethodChannel* _methodChannel; + NSString* _javaScriptChannelName; +} + +- (instancetype)initWithMethodChannel:(FlutterMethodChannel*)methodChannel + javaScriptChannelName:(NSString*)javaScriptChannelName { + self = [super init]; + NSAssert(methodChannel != nil, @"methodChannel must not be null."); + NSAssert(javaScriptChannelName != nil, @"javaScriptChannelName must not be null."); + if (self) { + _methodChannel = methodChannel; + _javaScriptChannelName = javaScriptChannelName; + } + return self; +} + +- (void)userContentController:(WKUserContentController*)userContentController + didReceiveScriptMessage:(WKScriptMessage*)message { + NSAssert(_methodChannel != nil, @"Can't send a message to an unitialized JavaScript channel."); + NSAssert(_javaScriptChannelName != nil, + @"Can't send a message to an unitialized JavaScript channel."); + NSDictionary* arguments = @{ + @"channel" : _javaScriptChannelName, + @"message" : [NSString stringWithFormat:@"%@", message.body] + }; + [_methodChannel invokeMethod:@"javascriptChannelMessage" arguments:arguments]; +} + +@end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/ios/webview_flutter_wkwebview.podspec b/packages/webview_flutter/webview_flutter_wkwebview/ios/webview_flutter_wkwebview.podspec new file mode 100644 index 000000000000..2dfb336df35a --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/ios/webview_flutter_wkwebview.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'webview_flutter_wkwebview' + s.version = '0.0.1' + s.summary = 'A WebView Plugin for Flutter.' + s.description = <<-DESC +A Flutter plugin that provides a WebView widget. +Downloaded by pub (not CocoaPods). + DESC + s.homepage = 'https://github.com/flutter/plugins' + s.license = { :type => 'BSD', :file => '../LICENSE' } + s.author = { 'Flutter Dev Team' => 'flutter-dev@googlegroups.com' } + s.source = { :http => 'https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_wkwebview' } + s.documentation_url = 'https://pub.dev/packages/webview_flutter' + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + + s.platform = :ios, '9.0' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } +end diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/src/webview_cupertino.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/webview_cupertino.dart new file mode 100644 index 000000000000..05b79d0a72e4 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/src/webview_cupertino.dart @@ -0,0 +1,49 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +/// Builds an iOS webview. +/// +/// This is used as the default implementation for [WebView.platform] on iOS. It uses +/// a [UiKitView] to embed the webview in the widget hierarchy, and uses a method channel to +/// communicate with the platform code. +class CupertinoWebView implements WebViewPlatform { + @override + Widget build({ + required BuildContext context, + required CreationParams creationParams, + required WebViewPlatformCallbacksHandler webViewPlatformCallbacksHandler, + required JavascriptChannelRegistry javascriptChannelRegistry, + WebViewPlatformCreatedCallback? onWebViewPlatformCreated, + Set>? gestureRecognizers, + }) { + return UiKitView( + viewType: 'plugins.flutter.io/webview', + onPlatformViewCreated: (int id) { + if (onWebViewPlatformCreated == null) { + return; + } + onWebViewPlatformCreated(MethodChannelWebViewPlatform( + id, + webViewPlatformCallbacksHandler, + javascriptChannelRegistry, + )); + }, + gestureRecognizers: gestureRecognizers, + creationParams: + MethodChannelWebViewPlatform.creationParamsToMap(creationParams), + creationParamsCodec: const StandardMessageCodec(), + ); + } + + @override + Future clearCookies() => MethodChannelWebViewPlatform.clearCookies(); +} diff --git a/packages/webview_flutter/webview_flutter_wkwebview/lib/webview_flutter_wkwebview.dart b/packages/webview_flutter/webview_flutter_wkwebview/lib/webview_flutter_wkwebview.dart new file mode 100644 index 000000000000..bbec415dccd0 --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/lib/webview_flutter_wkwebview.dart @@ -0,0 +1,5 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/webview_cupertino.dart'; diff --git a/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml b/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml new file mode 100644 index 000000000000..5176adb9749c --- /dev/null +++ b/packages/webview_flutter/webview_flutter_wkwebview/pubspec.yaml @@ -0,0 +1,28 @@ +name: webview_flutter_wkwebview +description: A Flutter plugin that provides a WebView widget based on Apple's WKWebView control. +repository: https://github.com/flutter/plugins/tree/master/packages/webview_flutter/webview_flutter_wkwebview +issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+webview%22 +version: 2.2.0 + +environment: + sdk: ">=2.14.0 <3.0.0" + flutter: ">=2.5.0" + +flutter: + plugin: + implements: webview_flutter + platforms: + ios: + pluginClass: FLTWebViewFlutterPlugin + +dependencies: + flutter: + sdk: flutter + webview_flutter_platform_interface: ^1.2.0 + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + pedantic: ^1.10.0 \ No newline at end of file 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..3840d64f33f3 --- /dev/null +++ b/script/configs/custom_analysis.yaml @@ -0,0 +1,41 @@ +# Plugins that deliberately use their own analysis_options.yaml. +# +# This only exists to allow incrementally switching to the newer, stricter +# analysis_options.yaml based on flutter/flutter, rather than the original +# rules based on pedantic (now at analysis_options_legacy.yaml). +# +# DO NOT add new entries to the list, unless it is to push the legacy rules +# from a top-level package into more specific packages in order to incrementally +# migrate a federated plugin. +# +# 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 +- google_maps_flutter +- google_sign_in +- image_picker +- in_app_purchase +- ios_platform_images +- local_auth +- quick_actions +- 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_ios.yaml b/script/configs/exclude_native_ios.yaml new file mode 100644 index 000000000000..723fcfa64715 --- /dev/null +++ b/script/configs/exclude_native_ios.yaml @@ -0,0 +1,7 @@ +# Deprecated; no plan to backfill the missing files +- battery +- connectivity/connectivity +- device_info/device_info +- package_info +- sensors +- wifi_info_flutter/wifi_info_flutter diff --git a/script/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/configs/exclude_native_unit_android.yaml b/script/configs/exclude_native_unit_android.yaml new file mode 100644 index 000000000000..5ec80eee73a0 --- /dev/null +++ b/script/configs/exclude_native_unit_android.yaml @@ -0,0 +1,11 @@ +# Deprecated; no plan to backfill the missing files +- android_alarm_manager +- battery +- device_info/device_info +- package_info +- sensors +- share +- wifi_info_flutter/wifi_info_flutter + +# No need for unit tests: +- espresso diff --git a/script/tool/lib/src/common/core.dart b/script/tool/lib/src/common/core.dart new file mode 100644 index 000000000000..53778eccb87f --- /dev/null +++ b/script/tool/lib/src/common/core.dart @@ -0,0 +1,106 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:colorize/colorize.dart'; +import 'package:file/file.dart'; +import 'package:yaml/yaml.dart'; + +/// The signature for a print handler for commands that allow overriding the +/// print destination. +typedef Print = void Function(Object? object); + +/// Key for APK (Android) platform. +const String kPlatformAndroid = 'android'; + +/// Key for IPA (iOS) platform. +const String kPlatformIos = 'ios'; + +/// Key for linux platform. +const String kPlatformLinux = 'linux'; + +/// 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'; + +/// Returns whether the given directory contains a Flutter package. +bool isFlutterPackage(FileSystemEntity entity) { + if (entity is! Directory) { + return false; + } + + try { + final File pubspecFile = entity.childFile('pubspec.yaml'); + final YamlMap pubspecYaml = + loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + final YamlMap? dependencies = pubspecYaml['dependencies'] as YamlMap?; + if (dependencies == null) { + return false; + } + return dependencies.containsKey('flutter'); + } on FileSystemException { + return false; + } on YamlException { + return false; + } +} + +/// Prints `successMessage` in green. +void printSuccess(String successMessage) { + print(Colorize(successMessage)..green()); +} + +/// Prints `errorMessage` in red. +void printError(String errorMessage) { + print(Colorize(errorMessage)..red()); +} + +/// Error thrown when a command needs to exit with a non-zero exit code. +/// +/// While there is no specific definition of the meaning of different non-zero +/// exit codes for this tool, commands should follow the general convention: +/// 1: The command ran correctly, but found errors. +/// 2: The command failed to run because the arguments were invalid. +/// >2: The command failed to run correctly for some other reason. Ideally, +/// each such failure should have a unique exit code within the context of +/// that command. +class ToolExit extends Error { + /// Creates a tool exit with the given [exitCode]. + ToolExit(this.exitCode); + + /// The code that the process should exit with. + final int exitCode; +} + +/// A exit code for [ToolExit] for a successful run that found errors. +const int exitCommandFoundErrors = 1; + +/// A exit code for [ToolExit] for a failure to run due to invalid arguments. +const int exitInvalidArguments = 2; diff --git a/script/tool/lib/src/common/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/git_version_finder.dart b/script/tool/lib/src/common/git_version_finder.dart new file mode 100644 index 000000000000..1cdd2fcc409b --- /dev/null +++ b/script/tool/lib/src/common/git_version_finder.dart @@ -0,0 +1,87 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; + +import 'package:git/git.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:yaml/yaml.dart'; + +/// Finding diffs based on `baseGitDir` and `baseSha`. +class GitVersionFinder { + /// Constructor + GitVersionFinder(this.baseGitDir, String? baseSha) : _baseSha = baseSha; + + /// The top level directory of the git repo. + /// + /// That is where the .git/ folder exists. + final GitDir baseGitDir; + + /// The base sha used to get diff. + String? _baseSha; + + static bool _isPubspec(String file) { + return file.trim().endsWith('pubspec.yaml'); + } + + /// Get a list of all the pubspec.yaml file that is changed. + Future> getChangedPubSpecs() async { + return (await getChangedFiles()).where(_isPubspec).toList(); + } + + /// Get a list of all the changed files. + Future> getChangedFiles() async { + final String baseSha = await getBaseSha(); + final io.ProcessResult changedFilesCommand = await baseGitDir + .runCommand(['diff', '--name-only', baseSha, 'HEAD']); + final String changedFilesStdout = changedFilesCommand.stdout.toString(); + if (changedFilesStdout.isEmpty) { + return []; + } + final List changedFiles = changedFilesStdout.split('\n') + ..removeWhere((String element) => element.isEmpty); + return changedFiles.toList(); + } + + /// Get the package version specified in the pubspec file in `pubspecPath` and + /// at the revision of `gitRef` (defaulting to the base if not provided). + Future getPackageVersion(String pubspecPath, + {String? gitRef}) async { + final String ref = gitRef ?? (await getBaseSha()); + + io.ProcessResult gitShow; + try { + gitShow = + await baseGitDir.runCommand(['show', '$ref:$pubspecPath']); + } on io.ProcessException { + return null; + } + final String fileContent = gitShow.stdout as String; + if (fileContent.trim().isEmpty) { + return null; + } + final String? versionString = loadYaml(fileContent)['version'] as String?; + return versionString == null ? null : Version.parse(versionString); + } + + /// Returns the base used to diff against. + Future getBaseSha() async { + String? baseSha = _baseSha; + if (baseSha != null && baseSha.isNotEmpty) { + return baseSha; + } + + io.ProcessResult baseShaFromMergeBase = await baseGitDir.runCommand( + ['merge-base', '--fork-point', 'FETCH_HEAD', 'HEAD'], + throwOnError: false); + if (baseShaFromMergeBase.stderr != null || + baseShaFromMergeBase.stdout == null) { + baseShaFromMergeBase = await baseGitDir + .runCommand(['merge-base', 'FETCH_HEAD', 'HEAD']); + } + baseSha = (baseShaFromMergeBase.stdout as String).trim(); + _baseSha = baseSha; + return baseSha; + } +} 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 new file mode 100644 index 000000000000..973ac9995cb8 --- /dev/null +++ b/script/tool/lib/src/common/package_looping_command.dart @@ -0,0 +1,404 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:colorize/colorize.dart'; +import 'package:file/file.dart'; +import 'package:git/git.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; + +import 'core.dart'; +import 'plugin_command.dart'; +import 'process_runner.dart'; +import 'repository_package.dart'; + +/// Possible outcomes of a command run for a package. +enum RunState { + /// The command succeeded for the package. + succeeded, + + /// The command was skipped for the package. + skipped, + + /// The command was skipped for the package because it was explicitly excluded + /// in the command arguments. + excluded, + + /// The command failed for the package. + failed, +} + +/// The result of a [runForPackage] call. +class PackageResult { + /// A successful result. + PackageResult.success() : this._(RunState.succeeded); + + /// A run that was skipped as explained in [reason]. + PackageResult.skip(String reason) + : this._(RunState.skipped, [reason]); + + /// A run that 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 + /// the summary will simply show that the package failed. + PackageResult.fail([List errors = const []]) + : this._(RunState.failed, errors); + + const PackageResult._(this.state, [this.details = const []]); + + /// The state the package run completed with. + final RunState state; + + /// Information about the result: + /// - For `succeeded`, this is empty. + /// - For `skipped`, it contains a single entry describing why the run was + /// skipped. + /// - For `failed`, it contains zero or more specific error details to be + /// shown in the summary. + final List details; +} + +/// An abstract base class for a command that iterates over a set of packages +/// controlled by a standard set of flags, running some actions on each package, +/// and collecting and reporting the success/failure of those actions. +abstract class PackageLoopingCommand extends PluginCommand { + /// Creates a command to operate on [packagesDir] with the given environment. + PackageLoopingCommand( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), + GitDir? gitDir, + }) : super(packagesDir, + processRunner: processRunner, platform: platform, gitDir: gitDir); + + /// Packages that had at least one [logWarning] call. + final Set _packagesWithWarnings = + {}; + + /// Number of warnings that happened outside of a [runForPackage] call. + int _otherWarningCount = 0; + + /// The package currently being run by [runForPackage]. + 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(RepositoryPackage package); + + /// Called during [run] after all calls to [runForPackage]. This provides an + /// opportunity to do any cleanup of run-level state. + Future completeRun() async {} + + /// If [captureOutput], this is called just before exiting with all captured + /// [output]. + Future handleCapturedOutput(List output) async {} + + /// Whether or not the output (if any) of [runForPackage] is long, or short. + /// + /// This changes the logging that happens at the start of each package's + /// run; long output gets a banner-style message to make it easier to find, + /// while short output gets a single-line entry. + /// + /// When this is false, runForPackage output should be indented if possible, + /// to make the output structure easier to follow. + bool get hasLongOutput => true; + + /// Whether to loop over all packages (e.g., including example/), rather than + /// only top-level packages. + bool get includeSubpackages => false; + + /// The text to output at the start when reporting one or more failures. + /// This will be followed by a list of packages that reported errors, with + /// the per-package details if any. + /// + /// This only needs to be overridden if the summary should provide extra + /// context. + String get failureListHeader => 'The following packages had errors:'; + + /// The text to output at the end when reporting one or more failures. This + /// will be printed immediately after the a list of packages that reported + /// errors. + /// + /// This only needs to be overridden if the summary should provide extra + /// context. + String get failureListFooter => 'See above for full details.'; + + /// 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. + /// + /// Capturing output will disable any colorizing of output from this base + /// class. + bool get captureOutput => false; + + // ---------------------------------------- + + /// Logs that a warning occurred, and prints `warningMessage` in yellow. + /// + /// Warnings are not surfaced in CI summaries, so this is only useful for + /// highlighting something when someone is already looking though the log + /// messages. DO NOT RELY on someone noticing a warning; instead, use it for + /// things that might be useful to someone debugging an unexpected result. + void logWarning(String warningMessage) { + print(Colorize(warningMessage)..yellow()); + if (_currentPackageEntry != null) { + _packagesWithWarnings.add(_currentPackageEntry!); + } else { + ++_otherWarningCount; + } + } + + /// Returns the relative path from [from] to [entity] in Posix style. + /// + /// This should be used when, for example, printing package-relative paths in + /// status or error messages. + String getRelativePosixPath( + FileSystemEntity entity, { + required Directory from, + }) => + p.posix.joinAll(path.split(path.relative(entity.path, from: from.path))); + + /// The suggested indentation for printed output. + String get indentation => hasLongOutput ? '' : ' '; + + // ---------------------------------------- + + @override + Future run() async { + bool succeeded; + if (captureOutput) { + final List output = []; + final ZoneSpecification logSwitchSpecification = ZoneSpecification( + print: (Zone self, ZoneDelegate parent, Zone zone, String message) { + output.add(message); + }); + succeeded = await runZoned>(_runInternal, + zoneSpecification: logSwitchSpecification); + await handleCapturedOutput(output); + } else { + succeeded = await _runInternal(); + } + + if (!succeeded) { + throw ToolExit(exitCommandFoundErrors); + } + } + + Future _runInternal() async { + _packagesWithWarnings.clear(); + _otherWarningCount = 0; + _currentPackageEntry = null; + + await initializeRun(); + + final List targetPackages = + await getPackagesToProcess().toList(); + + final Map results = + {}; + for (final PackageEnumerationEntry entry in targetPackages) { + _currentPackageEntry = entry; + _printPackageHeading(entry); + + // Command implementations should never see excluded packages; they are + // included at this level only for logging. + if (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[entry] = result; + } + _currentPackageEntry = null; + + completeRun(); + + print('\n'); + // If there were any errors reported, summarize them and exit. + if (results.values + .any((PackageResult result) => result.state == RunState.failed)) { + _printFailureSummary(targetPackages, results); + return false; + } + + // Otherwise, print a summary of what ran for ease of auditing that all the + // expected tests ran. + _printRunSummary(targetPackages, results); + + print('\n'); + _printSuccess('No issues found!'); + return true; + } + + void _printSuccess(String message) { + captureOutput ? print(message) : printSuccess(message); + } + + void _printError(String message) { + captureOutput ? print(message) : printError(message); + } + + /// Prints the status message indicating that the command is being run for + /// [package]. + /// + /// Something is always printed to make it easier to distinguish between + /// a command running for a package and producing no output, and a command + /// not having been run for a package. + void _printPackageHeading(PackageEnumerationEntry entry) { + final String packageDisplayName = entry.package.displayName; + String heading = entry.excluded + ? 'Not running for $packageDisplayName; excluded' + : 'Running for $packageDisplayName'; + if (hasLongOutput) { + heading = ''' + +============================================================ +|| $heading +============================================================ +'''; + } else if (!entry.excluded) { + heading = '$heading...'; + } + 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) => + entry.value.state == RunState.skipped) + .map((MapEntry entry) => + entry.key) + .toSet(); + 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 = + _packagesWithWarnings.intersection(skippedPackages); + final int skippedWarningCount = _skippedPackagesWithWarnings.length; + final int runWarningCount = + _packagesWithWarnings.length - skippedWarningCount; + + final String runWarningSummary = + runWarningCount > 0 ? ' ($runWarningCount with warnings)' : ''; + final String skippedWarningSummary = + runWarningCount > 0 ? ' ($skippedWarningCount with warnings)' : ''; + print('------------------------------------------------------------'); + if (hasLongOutput) { + _printPerPackageRunOverview(packages, skipped: skippedPackages); + } + print( + 'Ran for ${packages.length - skipCount} package(s)$runWarningSummary'); + if (skipCount > 0) { + print('Skipped $skipCount package(s)$skippedWarningSummary'); + } + if (_otherWarningCount > 0) { + print('$_otherWarningCount warnings not associated with a package'); + } + } + + /// Prints a one-line-per-package overview of the run results for each + /// package. + void _printPerPackageRunOverview( + List packageEnumeration, + {required Set skipped}) { + print('Run overview:'); + for (final PackageEnumerationEntry entry in packageEnumeration) { + final bool hadWarning = _packagesWithWarnings.contains(entry); + Styles style; + String summary; + 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 = successSummaryMessage; + style = hadWarning ? Styles.YELLOW : Styles.GREEN; + } + if (hadWarning) { + summary += ' (with warning)'; + } + + if (!captureOutput) { + summary = (Colorize(summary)..apply(style)).toString(); + } + print(' ${entry.package.displayName} - $summary'); + } + print(''); + } + + /// Prints a summary of all of the failures from [results]. + void _printFailureSummary(List packageEnumeration, + Map results) { + const String indentation = ' '; + _printError(failureListHeader); + for (final PackageEnumerationEntry entry in packageEnumeration) { + final PackageResult result = results[entry]!; + if (result.state == RunState.failed) { + final String errorIndentation = indentation * 2; + String errorDetails = ''; + if (result.details.isNotEmpty) { + errorDetails = + ':\n$errorIndentation${result.details.join('\n$errorIndentation')}'; + } + _printError('$indentation${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 new file mode 100644 index 000000000000..5d5cbd9abf6c --- /dev/null +++ b/script/tool/lib/src/common/plugin_command.dart @@ -0,0 +1,477 @@ +// Copyright 2013 The Flutter 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 'dart:math'; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:git/git.dart'; +import 'package:path/path.dart' as p; +import '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. +abstract class PluginCommand extends Command { + /// Creates a command to operate on [packagesDir] with the given environment. + PluginCommand( + this.packagesDir, { + this.processRunner = const ProcessRunner(), + this.platform = const LocalPlatform(), + GitDir? gitDir, + }) : _gitDir = gitDir { + argParser.addMultiOption( + _packagesArg, + splitCommas: true, + help: + 'Specifies which packages the command should run on (before sharding).\n', + valueHelp: 'package1,package2,...', + aliases: [_pluginsArg], + ); + argParser.addOption( + _shardIndexArg, + help: 'Specifies the zero-based index of the shard to ' + 'which the command applies.', + valueHelp: 'i', + defaultsTo: '0', + ); + argParser.addOption( + _shardCountArg, + help: 'Specifies the number of shards into which plugins are divided.', + valueHelp: 'n', + defaultsTo: '1', + ); + argParser.addMultiOption( + _excludeArg, + abbr: 'e', + help: '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 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.\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' + 'If not specified, merge-base is used as base sha.'); + } + + static const String _pluginsArg = 'plugins'; + static const String _packagesArg = 'packages'; + static const String _shardIndexArg = 'shardIndex'; + static const String _shardCountArg = 'shardCount'; + static const String _excludeArg = 'exclude'; + 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. + final Directory packagesDir; + + /// The process runner. + /// + /// This can be overridden for testing. + final ProcessRunner processRunner; + + /// The current platform. + /// + /// This can be overridden for testing. + final Platform platform; + + /// The git directory to use. If unset, [gitDir] populates it from the + /// packages directory's enclosing repository. + /// + /// This can be mocked for testing. + GitDir? _gitDir; + + 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; + + /// The command to use when running `flutter`. + String get flutterCommand => platform.isWindows ? 'flutter.bat' : 'flutter'; + + /// The shard of the overall command execution that this instance should run. + int get shardIndex { + if (_shardIndex == null) { + _checkSharding(); + } + return _shardIndex!; + } + + /// The number of shards this command is divided into. + int get shardCount { + if (_shardCount == null) { + _checkSharding(); + } + return _shardCount!; + } + + /// Returns the [GitDir] containing [packagesDir]. + Future get gitDir async { + GitDir? gitDir = _gitDir; + if (gitDir != null) { + return gitDir; + } + + // Ensure there are no symlinks in the path, as it can break + // GitDir's allowSubdirectory:true. + final String packagesPath = packagesDir.resolveSymbolicLinksSync(); + if (!await GitDir.isGitDir(packagesPath)) { + printError('$packagesPath is not a valid Git repository.'); + throw ToolExit(2); + } + gitDir = + await GitDir.fromExisting(packagesDir.path, allowSubdirectory: true); + _gitDir = gitDir; + return gitDir; + } + + /// Convenience accessor for boolean arguments. + bool getBoolArg(String key) { + return (argResults![key] as bool?) ?? false; + } + + /// Convenience accessor for String arguments. + String getStringArg(String key) { + return (argResults![key] as String?) ?? ''; + } + + /// Convenience accessor for List arguments. + List getStringListArg(String key) { + return (argResults![key] as List?) ?? []; + } + + void _checkSharding() { + final int? shardIndex = int.tryParse(getStringArg(_shardIndexArg)); + final int? shardCount = int.tryParse(getStringArg(_shardCountArg)); + if (shardIndex == null) { + usageException('$_shardIndexArg must be an integer'); + } + if (shardCount == null) { + usageException('$_shardCountArg must be an integer'); + } + if (shardCount < 1) { + usageException('$_shardCountArg must be positive'); + } + if (shardIndex < 0 || shardCount <= shardIndex) { + usageException( + '$_shardIndexArg must be in the half-open range [0..$shardCount['); + } + _shardIndex = shardIndex; + _shardCount = shardCount; + } + + /// Returns the 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 _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 PackageEnumerationEntry plugin + in allPlugins.sublist(start, end)) { + if (!(filterExcluded && plugin.excluded)) { + yield plugin; + } + } + } + + /// 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. + /// + /// 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 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 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 _getAllPackages() async* { + final Set packageSelectionFlags = { + _packagesArg, + _runOnChangedPackagesArg, + _packagesForBranchArg, + }; + if (packageSelectionFlags + .where((String flag) => argResults!.wasParsed(flag)) + .length > + 1) { + printError('Only one of --$_packagesArg, --$_runOnChangedPackagesArg, or ' + '--$_packagesForBranchArg can be provided.'); + throw ToolExit(exitInvalidArguments); + } + + Set plugins = Set.from(getStringListArg(_packagesArg)); + + final bool runOnChangedPackages; + if (getBoolArg(_runOnChangedPackagesArg)) { + runOnChangedPackages = true; + } else if (getBoolArg(_packagesForBranchArg)) { + final String? branch = await _getBranch(); + if (branch == null) { + printError('Unabled to determine branch; --$_packagesForBranchArg can ' + 'only be used in a git repository.'); + throw ToolExit(exitInvalidArguments); + } else { + runOnChangedPackages = branch != 'master'; + // Log the mode for auditing what was intended to run. + print('--$_packagesForBranchArg: running on ' + '${runOnChangedPackages ? 'changed' : 'all'} packages'); + } + } else { + runOnChangedPackages = false; + } + + final Set excludedPluginNames = getExcludedPackageNames(); + + if (runOnChangedPackages) { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final String baseSha = await gitVersionFinder.getBaseSha(); + print( + 'Running for all packages that have changed relative to "$baseSha"\n'); + final List changedFiles = + await gitVersionFinder.getChangedFiles(); + if (!_changesRequireFullTest(changedFiles)) { + plugins = _getChangedPackages(changedFiles); + } + } + + final Directory thirdPartyPackagesDirectory = packagesDir.parent + .childDirectory('third_party') + .childDirectory('packages'); + + for (final Directory dir in [ + packagesDir, + if (thirdPartyPackagesDirectory.existsSync()) thirdPartyPackagesDirectory, + ]) { + await for (final FileSystemEntity entity + in dir.list(followLinks: false)) { + // A top-level Dart package is a plugin package. + if (_isDartPackage(entity)) { + if (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. + await for (final FileSystemEntity subdir + in entity.list(followLinks: false)) { + if (_isDartPackage(subdir)) { + // If --plugin=my_plugin is passed, then match all federated + // plugins under 'my_plugin'. Also match if the exact plugin is + // passed. + final String relativePath = + path.relative(subdir.path, from: dir.path); + final String packageName = path.basename(subdir.path); + final String basenamePath = path.basename(entity.path); + 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)); + } + } + } + } + } + } + } + + /// 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.package.directory + .list(recursive: true, followLinks: false) + .where(_isDartPackage) + .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 packages + /// involved in this command execution. + Stream getFiles() { + return getTargetPackages().asyncExpand( + (PackageEnumerationEntry entry) => getFilesForPackage(entry.package)); + } + + /// Returns the files contained, recursively, within [package]. + Stream getFilesForPackage(RepositoryPackage package) { + return package.directory + .list(recursive: true, followLinks: false) + .where((FileSystemEntity entity) => entity is File) + .cast(); + } + + /// Returns whether the specified entity is a directory containing a + /// `pubspec.yaml` file. + bool _isDartPackage(FileSystemEntity entity) { + return entity is Directory && entity.childFile('pubspec.yaml').existsSync(); + } + + /// Retrieve an instance of [GitVersionFinder] based on `_kBaseSha` and [gitDir]. + /// + /// Throws tool exit if [gitDir] nor root directory is a git directory. + Future retrieveVersionFinder() async { + final String baseSha = getStringArg(_kBaseSha); + + final GitVersionFinder gitVersionFinder = + GitVersionFinder(await gitDir, baseSha); + return gitVersionFinder; + } + + // 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 changedFiles) { + final List pathComponents = p.posix.split(path); + final int packagesIndex = + pathComponents.indexWhere((String element) => element == 'packages'); + if (packagesIndex != -1) { + packages.add(pathComponents[packagesIndex + 1]); + } + } + if (packages.isEmpty) { + print('No changed packages.'); + } else { + final String changedPackages = packages.join(','); + print('Changed packages: $changedPackages'); + } + return packages; + } + + 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). + bool _changesRequireFullTest(List changedFiles) { + const List specialFiles = [ + '.ci.yaml', // LUCI config. + '.cirrus.yml', // Cirrus config. + '.clang-format', // ObjC and C/C++ formatting options. + 'analysis_options.yaml', // Dart analysis settings. + ]; + const List specialDirectories = [ + '.ci/', // Support files for CI. + 'script/', // This tool, and its wrapper scripts. + ]; + // Directory entries must end with / to avoid over-matching, since the + // check below is done via string prefixing. + assert(specialDirectories.every((String dir) => dir.endsWith('/'))); + + 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 new file mode 100644 index 000000000000..6cfe9928d689 --- /dev/null +++ b/script/tool/lib/src/common/plugin_utils.dart @@ -0,0 +1,146 @@ +// Copyright 2013 The Flutter 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/repository_package.dart'; +import 'package:yaml/yaml.dart'; + +import 'core.dart'; + +/// Possible plugin support options for a platform. +enum PlatformSupport { + /// The platform has an implementation in the package. + inline, + + /// The platform has an endorsed federated implementation in another package. + federated, +} + +/// Returns 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: +/// +/// flutter: +/// plugin: +/// platforms: +/// [platform]: +/// +/// If [requiredMode] is provided, the plugin must have the given type of +/// implementation in order to return true. +bool pluginSupportsPlatform( + String platform, + RepositoryPackage plugin, { + PlatformSupport? requiredMode, + String? variant, +}) { + assert(platform == kPlatformIos || + platform == kPlatformAndroid || + platform == kPlatformWeb || + platform == kPlatformMacos || + platform == kPlatformWindows || + platform == kPlatformLinux); + + final YamlMap? platformEntry = + _readPlatformPubspecSectionForPlugin(platform, plugin); + if (platformEntry == null) { + return false; + } + + // If the platform entry is present, then it supports the platform. Check + // for required mode if specified. + if (requiredMode != null) { + final bool federated = platformEntry.containsKey('default_package'); + if (federated != (requiredMode == PlatformSupport.federated)) { + return false; + } + } + + // If a variant is specified, check for that variant. + if (variant != null) { + const String variantsKey = 'supportedVariants'; + if (platformEntry.containsKey(variantsKey)) { + if (!(platformEntry['supportedVariants']! as YamlList) + .contains(variant)) { + return false; + } + } else { + // Platforms with variants have a default variant when unspecified for + // backward compatibility. Must match the flutter tool logic. + const Map defaultVariants = { + kPlatformWindows: platformVariantWin32, + }; + if (variant != defaultVariants[platform]) { + return false; + } + } + } + + return true; +} + +/// 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 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 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/process_runner.dart b/script/tool/lib/src/common/process_runner.dart new file mode 100644 index 000000000000..429761ead3b8 --- /dev/null +++ b/script/tool/lib/src/common/process_runner.dart @@ -0,0 +1,104 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io' as io; + +import 'package:file/file.dart'; + +import 'core.dart'; + +/// A class used to run processes. +/// +/// We use this instead of directly running the process so it can be overridden +/// in tests. +class ProcessRunner { + /// Creates a new process runner. + const ProcessRunner(); + + /// Run the [executable] with [args] and stream output to stderr and stdout. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// If [exitOnError] is set to `true`, then this will throw an error if + /// the [executable] terminates with a non-zero exit code. + /// + /// Returns the exit code of the [executable]. + Future runAndStream( + String executable, + List args, { + Directory? workingDir, + bool exitOnError = false, + }) async { + print( + 'Running command: "$executable ${args.join(' ')}" in ${workingDir?.path ?? io.Directory.current.path}'); + final io.Process process = await io.Process.start(executable, args, + workingDirectory: workingDir?.path); + await io.stdout.addStream(process.stdout); + await io.stderr.addStream(process.stderr); + if (exitOnError && await process.exitCode != 0) { + final String error = + _getErrorString(executable, args, workingDir: workingDir); + print('$error See above for details.'); + throw ToolExit(await process.exitCode); + } + return process.exitCode; + } + + /// Run the [executable] with [args]. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// If [exitOnError] is set to `true`, then this will throw an error if + /// the [executable] terminates with a non-zero exit code. + /// Defaults to `false`. + /// + /// If [logOnError] is set to `true`, it will print a formatted message about the error. + /// Defaults to `false` + /// + /// Returns the [io.ProcessResult] of the [executable]. + Future run(String executable, List args, + {Directory? workingDir, + bool exitOnError = false, + bool logOnError = false, + Encoding stdoutEncoding = io.systemEncoding, + Encoding stderrEncoding = io.systemEncoding}) async { + final io.ProcessResult result = await io.Process.run(executable, args, + workingDirectory: workingDir?.path, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding); + if (result.exitCode != 0) { + if (logOnError) { + final String error = + _getErrorString(executable, args, workingDir: workingDir); + print('$error Stderr:\n${result.stdout}'); + } + if (exitOnError) { + throw ToolExit(result.exitCode); + } + } + return result; + } + + /// Starts the [executable] with [args]. + /// + /// The current working directory of [executable] can be overridden by + /// passing [workingDir]. + /// + /// Returns the started [io.Process]. + Future start(String executable, List args, + {Directory? workingDirectory}) async { + final io.Process process = await io.Process.start(executable, args, + workingDirectory: workingDirectory?.path); + return process; + } + + String _getErrorString(String executable, List args, + {Directory? workingDir}) { + final String workdir = workingDir == null ? '' : ' in ${workingDir.path}'; + return 'ERROR: Unable to execute "$executable ${args.join(' ')}"$workdir.'; + } +} diff --git a/script/tool/lib/src/common/pub_version_finder.dart b/script/tool/lib/src/common/pub_version_finder.dart new file mode 100644 index 000000000000..572cb913aa7d --- /dev/null +++ b/script/tool/lib/src/common/pub_version_finder.dart @@ -0,0 +1,103 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:pub_semver/pub_semver.dart'; + +/// Finding version of [package] that is published on pub. +class PubVersionFinder { + /// Constructor. + /// + /// Note: you should manually close the [httpClient] when done using the finder. + PubVersionFinder({this.pubHost = defaultPubHost, required this.httpClient}); + + /// The default pub host to use. + static const String defaultPubHost = 'https://pub.dev'; + + /// The pub host url, defaults to `https://pub.dev`. + final String pubHost; + + /// The http client. + /// + /// You should manually close this client when done using this finder. + final http.Client httpClient; + + /// Get the package version on pub. + Future getPackageVersion( + {required String packageName}) async { + assert(packageName.isNotEmpty); + final Uri pubHostUri = Uri.parse(pubHost); + final Uri url = pubHostUri.replace(path: '/packages/$packageName.json'); + final http.Response response = await httpClient.get(url); + + if (response.statusCode == 404) { + return PubVersionFinderResponse( + versions: [], + result: PubVersionFinderResult.noPackageFound, + httpResponse: response); + } else if (response.statusCode != 200) { + return PubVersionFinderResponse( + versions: [], + result: PubVersionFinderResult.fail, + httpResponse: response); + } + final List versions = + (json.decode(response.body)['versions'] as List) + .map((final dynamic versionString) => + Version.parse(versionString as String)) + .toList(); + + return PubVersionFinderResponse( + versions: versions, + result: PubVersionFinderResult.success, + httpResponse: response); + } +} + +/// Represents a response for [PubVersionFinder]. +class PubVersionFinderResponse { + /// Constructor. + PubVersionFinderResponse( + {required this.versions, + required this.result, + required this.httpResponse}) { + if (versions.isNotEmpty) { + versions.sort((Version a, Version b) { + // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize]. + // https://github.com/flutter/flutter/issues/82222 + return b.compareTo(a); + }); + } + } + + /// The versions found in [PubVersionFinder]. + /// + /// This is sorted by largest to smallest, so the first element in the list is the largest version. + /// Might be `null` if the [result] is not [PubVersionFinderResult.success]. + final List versions; + + /// The result of the version finder. + final PubVersionFinderResult result; + + /// The response object of the http request. + final http.Response httpResponse; +} + +/// An enum representing the result of [PubVersionFinder]. +enum PubVersionFinderResult { + /// The version finder successfully found a version. + success, + + /// The version finder failed to find a valid version. + /// + /// This might due to http connection errors or user errors. + fail, + + /// The version finder failed to locate the package. + /// + /// This indicates the package is new. + noPackageFound, +} diff --git a/script/tool/lib/src/common/repository_package.dart b/script/tool/lib/src/common/repository_package.dart new file mode 100644 index 000000000000..3b4417ac8182 --- /dev/null +++ b/script/tool/lib/src/common/repository_package.dart @@ -0,0 +1,98 @@ +// Copyright 2013 The Flutter 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'); + + /// True if this appears to be a federated plugin package, according to + /// repository conventions. + bool get isFederated => + directory.parent.basename != 'packages' && + directory.basename.startsWith(directory.parent.basename); + + /// True if this appears to be a platform interface package, according to + /// repository conventions. + bool get isPlatformInterface => + directory.basename.endsWith('_platform_interface'); + + /// True if this appears to be a platform implementation package, according to + /// repository conventions. + bool get isPlatformImplementation => + // Any part of a federated plugin that isn't the platform interface and + // isn't the app-facing package should be an implementation package. + isFederated && + !isPlatformInterface && + directory.basename != directory.parent.basename; + + /// Returns the Flutter example packages contained in the package, if any. + Iterable getExamples() { + final Directory exampleDirectory = directory.childDirectory('example'); + 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/federation_safety_check_command.dart b/script/tool/lib/src/federation_safety_check_command.dart new file mode 100644 index 000000000000..200f9c3f48cb --- /dev/null +++ b/script/tool/lib/src/federation_safety_check_command.dart @@ -0,0 +1,195 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/file.dart'; +import 'package:flutter_plugin_tools/src/common/plugin_utils.dart'; +import 'package:git/git.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +import 'common/core.dart'; +import 'common/file_utils.dart'; +import 'common/git_version_finder.dart'; +import 'common/package_looping_command.dart'; +import 'common/process_runner.dart'; +import 'common/repository_package.dart'; + +/// A command to check that PRs don't violate repository best practices that +/// have been established to avoid breakages that building and testing won't +/// catch. +class FederationSafetyCheckCommand extends PackageLoopingCommand { + /// Creates an instance of the safety check command. + FederationSafetyCheckCommand( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), + GitDir? gitDir, + }) : super( + packagesDir, + processRunner: processRunner, + platform: platform, + gitDir: gitDir, + ); + + // A map of package name (as defined by the directory name of the package) + // to a list of changed Dart files in that package, as Posix paths relative to + // the package root. + // + // This only considers top-level packages, not subpackages such as example/. + final Map> _changedDartFiles = >{}; + + // The set of *_platform_interface packages that will have public code changes + // published. + final Set _modifiedAndPublishedPlatformInterfacePackages = {}; + + // The set of conceptual plugins (not packages) that have changes. + final Set _changedPlugins = {}; + + static const String _platformInterfaceSuffix = '_platform_interface'; + + @override + final String name = 'federation-safety-check'; + + @override + final String description = + 'Checks that the change does not violate repository rules around changes ' + 'to federated plugin packages.'; + + @override + bool get hasLongOutput => false; + + @override + Future initializeRun() async { + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final String baseSha = await gitVersionFinder.getBaseSha(); + print('Validating changes relative to "$baseSha"\n'); + for (final String path in await gitVersionFinder.getChangedFiles()) { + // Git output always uses Posix paths. + final List allComponents = p.posix.split(path); + final int packageIndex = allComponents.indexOf('packages'); + if (packageIndex == -1) { + continue; + } + final List relativeComponents = + allComponents.sublist(packageIndex + 1); + // The package name is either the directory directly under packages/, or + // the directory under that in the case of a federated plugin. + String packageName = relativeComponents.removeAt(0); + // Count the top-level plugin as changed. + _changedPlugins.add(packageName); + if (relativeComponents[0] == packageName || + relativeComponents[0].startsWith('${packageName}_')) { + packageName = relativeComponents.removeAt(0); + } + + if (relativeComponents.last.endsWith('.dart')) { + _changedDartFiles[packageName] ??= []; + _changedDartFiles[packageName]! + .add(p.posix.joinAll(relativeComponents)); + } + + if (packageName.endsWith(_platformInterfaceSuffix) && + relativeComponents.first == 'pubspec.yaml' && + await _packageWillBePublished(path)) { + _modifiedAndPublishedPlatformInterfacePackages.add(packageName); + } + } + } + + @override + Future runForPackage(RepositoryPackage package) async { + if (!isFlutterPlugin(package)) { + return PackageResult.skip('Not a plugin.'); + } + + if (!package.isFederated) { + return PackageResult.skip('Not a federated plugin.'); + } + + if (package.isPlatformInterface) { + // As the leaf nodes in the graph, a published package interface change is + // assumed to be correct, and other changes are validated against that. + return PackageResult.skip( + 'Platform interface changes are not validated.'); + } + + // Uses basename to match _changedPackageFiles. + final String basePackageName = package.directory.parent.basename; + final String platformInterfacePackageName = + '$basePackageName$_platformInterfaceSuffix'; + final List changedPlatformInterfaceFiles = + _changedDartFiles[platformInterfacePackageName] ?? []; + + if (!_modifiedAndPublishedPlatformInterfacePackages + .contains(platformInterfacePackageName)) { + print('No published changes for $platformInterfacePackageName.'); + return PackageResult.success(); + } + + if (!changedPlatformInterfaceFiles + .any((String path) => path.startsWith('lib/'))) { + print('No public code changes for $platformInterfacePackageName.'); + return PackageResult.success(); + } + + final List changedPackageFiles = + _changedDartFiles[package.directory.basename] ?? []; + if (changedPackageFiles.isEmpty) { + print('No Dart changes.'); + return PackageResult.success(); + } + + // If the change would be flagged, but it appears to be a mass change + // rather than a plugin-specific change, allow it with a warning. + // + // This is a tradeoff between safety and convenience; forcing mass changes + // to be split apart is not ideal, and the assumption is that reviewers are + // unlikely to accidentally approve a PR that is supposed to be changing a + // single plugin, but touches other plugins (vs accidentally approving a + // PR that changes multiple parts of a single plugin, which is a relatively + // easy mistake to make). + // + // 3 is chosen to minimize the chances of accidentally letting something + // through (vs 2, which could be a single-plugin change with one stray + // change to another file accidentally included), while not setting too + // high a bar for detecting mass changes. This can be tuned if there are + // issues with false positives or false negatives. + const int massChangePluginThreshold = 3; + if (_changedPlugins.length >= massChangePluginThreshold) { + logWarning('Ignoring potentially dangerous change, as this appears ' + 'to be a mass change.'); + return PackageResult.success(); + } + + printError('Dart changes are not allowed to other packages in ' + '$basePackageName in the same PR as changes to public Dart code in ' + '$platformInterfacePackageName, as this can cause accidental breaking ' + 'changes to be missed by automated checks. Please split the changes to ' + 'these two packages into separate PRs.\n\n' + 'If you believe that this is a false positive, please file a bug.'); + return PackageResult.fail( + ['$platformInterfacePackageName changed.']); + } + + Future _packageWillBePublished( + String pubspecRepoRelativePosixPath) async { + final File pubspecFile = childFileWithSubcomponents( + packagesDir.parent, p.posix.split(pubspecRepoRelativePosixPath)); + final Pubspec pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); + if (pubspec.publishTo == 'none') { + return false; + } + + final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); + final Version? previousVersion = + await gitVersionFinder.getPackageVersion(pubspecRepoRelativePosixPath); + if (previousVersion == null) { + // The plugin is new, so it will be published. + return true; + } + return pubspec.version != previousVersion; + } +} diff --git a/script/tool/lib/src/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/native_test_command.dart b/script/tool/lib/src/native_test_command.dart new file mode 100644 index 000000000000..4911b4aeb156 --- /dev/null +++ b/script/tool/lib/src/native_test_command.dart @@ -0,0 +1,587 @@ +// Copyright 2013 The Flutter 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 ranUnitTests = false; + bool ranAnyTests = false; + bool failed = false; + bool hasMissingBuild = false; + for (final RepositoryPackage example in examples) { + final bool hasUnitTests = exampleHasUnitTests(example); + final bool hasIntegrationTests = + exampleHasNativeIntegrationTests(example); + + if (mode.unit && !hasUnitTests) { + _printNoExampleTestsMessage(example, 'Android unit'); + } + if (mode.integration && !hasIntegrationTests) { + _printNoExampleTestsMessage(example, 'Android integration'); + } + + final bool runUnitTests = mode.unit && hasUnitTests; + final bool runIntegrationTests = mode.integration && hasIntegrationTests; + if (!runUnitTests && !runIntegrationTests) { + continue; + } + + final String exampleName = 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; + } + ranUnitTests = true; + ranAnyTests = 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; + } + ranAnyTests = true; + } + } + + if (failed) { + return _PlatformResult(RunState.failed, + error: hasMissingBuild + ? 'Examples must be built before testing.' + : null); + } + if (!mode.integrationOnly && !ranUnitTests) { + printError('No unit tests ran. Plugins are required to have unit tests.'); + return _PlatformResult(RunState.failed, + error: 'No unit tests ran (use --exclude if this is intentional).'); + } + if (!ranAnyTests) { + return _PlatformResult(RunState.skipped); + } + return _PlatformResult(RunState.succeeded); + } + + 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; + const String unitTestTarget = 'RunnerTests'; + if (mode.unitOnly) { + testTarget = unitTestTarget; + } else if (mode.integrationOnly) { + testTarget = 'RunnerUITests'; + } + + bool ranUnitTests = false; + // Assume skipped until at least one test has run. + RunState overallResult = RunState.skipped; + for (final RepositoryPackage example in plugin.getExamples()) { + final String exampleName = example.displayName; + + // If running a specific target, check that. Otherwise, check if there + // are unit tests, since having no unit tests for a plugin is fatal + // (by repo policy) even if there are integration tests. + bool exampleHasUnitTests = false; + final String? targetToCheck = + testTarget ?? (mode.unit ? unitTestTarget : null); + final Directory xcodeProject = example.directory + .childDirectory(platform.toLowerCase()) + .childDirectory('Runner.xcodeproj'); + if (targetToCheck != null) { + final bool? hasTarget = + await _xcode.projectHasTarget(xcodeProject, targetToCheck); + if (hasTarget == null) { + printError('Unable to check targets for $exampleName.'); + overallResult = RunState.failed; + continue; + } else if (!hasTarget) { + print('No "$targetToCheck" target in $exampleName; skipping.'); + continue; + } else if (targetToCheck == unitTestTarget) { + exampleHasUnitTests = true; + } + } + + _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); + break; + case 0: + printSuccess('Successfully ran $platform xctest for $exampleName'); + // If this is the first test, assume success until something fails. + if (overallResult == RunState.skipped) { + overallResult = RunState.succeeded; + } + if (exampleHasUnitTests) { + ranUnitTests = true; + } + break; + default: + // Any failure means a failure overall. + overallResult = RunState.failed; + // If unit tests ran, note that even if they failed. + if (exampleHasUnitTests) { + ranUnitTests = true; + } + break; + } + } + + if (!mode.integrationOnly && !ranUnitTests) { + printError('No unit tests ran. Plugins are required to have unit tests.'); + // Only return a specific summary error message about the missing unit + // tests if there weren't also failures, to avoid having a misleadingly + // specific message. + if (overallResult != RunState.failed) { + return _PlatformResult(RunState.failed, + error: 'No unit tests ran (use --exclude if this is intentional).'); + } + } + + return _PlatformResult(overallResult); + } + + 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/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/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/git_version_finder_test.dart b/script/tool/test/common/git_version_finder_test.dart new file mode 100644 index 000000000000..f1f40b5e0035 --- /dev/null +++ b/script/tool/test/common/git_version_finder_test.dart @@ -0,0 +1,93 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter_plugin_tools/src/common/git_version_finder.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'plugin_command_test.mocks.dart'; + +void main() { + late List?> gitDirCommands; + late String gitDiffResponse; + late MockGitDir gitDir; + String? mergeBaseResponse; + + setUp(() { + gitDirCommands = ?>[]; + gitDiffResponse = ''; + gitDir = MockGitDir(); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + gitDirCommands.add(invocation.positionalArguments[0] as List?); + final MockProcessResult mockProcessResult = MockProcessResult(); + if (invocation.positionalArguments[0][0] == 'diff') { + when(mockProcessResult.stdout as String?) + .thenReturn(gitDiffResponse); + } else if (invocation.positionalArguments[0][0] == 'merge-base') { + when(mockProcessResult.stdout as String?) + .thenReturn(mergeBaseResponse); + } + return Future.value(mockProcessResult); + }); + }); + + test('No git diff should result no files changed', () async { + final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); + final List changedFiles = await finder.getChangedFiles(); + + expect(changedFiles, isEmpty); + }); + + test('get correct files changed based on git diff', () async { + gitDiffResponse = ''' +file1/file1.cc +file2/file2.cc +'''; + final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); + final List changedFiles = await finder.getChangedFiles(); + + expect(changedFiles, equals(['file1/file1.cc', 'file2/file2.cc'])); + }); + + test('get correct pubspec change based on git diff', () async { + gitDiffResponse = ''' +file1/pubspec.yaml +file2/file2.cc +'''; + final GitVersionFinder finder = GitVersionFinder(gitDir, 'some base sha'); + final List changedFiles = await finder.getChangedPubSpecs(); + + expect(changedFiles, equals(['file1/pubspec.yaml'])); + }); + + test('use correct base sha if not specified', () async { + mergeBaseResponse = 'shaqwiueroaaidf12312jnadf123nd'; + gitDiffResponse = ''' +file1/pubspec.yaml +file2/file2.cc +'''; + + final GitVersionFinder finder = GitVersionFinder(gitDir, null); + await finder.getChangedFiles(); + verify(gitDir.runCommand( + ['diff', '--name-only', mergeBaseResponse!, 'HEAD'])); + }); + + test('use correct base sha if specified', () async { + const String customBaseSha = 'aklsjdcaskf12312'; + gitDiffResponse = ''' +file1/pubspec.yaml +file2/file2.cc +'''; + final GitVersionFinder finder = GitVersionFinder(gitDir, customBaseSha); + await finder.getChangedFiles(); + verify(gitDir + .runCommand(['diff', '--name-only', customBaseSha, 'HEAD'])); + }); +} + +class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool/test/common/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 new file mode 100644 index 000000000000..7cf03960a74d --- /dev/null +++ b/script/tool/test/common/package_looping_command_test.dart @@ -0,0 +1,732 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:io' 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/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'; +import 'package:test/test.dart'; + +import '../mocks.dart'; +import '../util.dart'; +import 'plugin_command_test.mocks.dart'; + +// Constants for colorized output start and end. +const String _startErrorColor = '\x1B[31m'; +const String _startHeadingColor = '\x1B[36m'; +const String _startSkipColor = '\x1B[90m'; +const String _startSkipWithWarningColor = '\x1B[93m'; +const String _startSuccessColor = '\x1B[32m'; +const String _startWarningColor = '\x1B[33m'; +const String _endColor = '\x1B[0m'; + +// The filename within a package containing errors to return from runForPackage. +const String _errorFile = 'errors'; +// The filename within a package indicating that it should be skipped. +const String _skipFile = 'skip'; +// The filename within a package containing warnings to log during runForPackage. +const String _warningFile = 'warnings'; +// The filename within a package indicating that it should throw. +const String _throwFile = 'throw'; + +void main() { + late FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late Directory thirdPartyPackagesDir; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + thirdPartyPackagesDir = packagesDir.parent + .childDirectory('third_party') + .childDirectory('packages'); + }); + + /// Creates a TestPackageLoopingCommand instance that uses [gitDiffResponse] + /// for git diffs, and logs output to [printOutput]. + TestPackageLoopingCommand createTestCommand({ + String gitDiffResponse = '', + bool hasLongOutput = true, + bool includeSubpackages = false, + bool failsDuringInit = false, + bool warnsDuringInit = false, + bool warnsDuringCleanup = false, + bool captureOutput = false, + String? customFailureListHeader, + String? customFailureListFooter, + }) { + // Set up the git diff response. + final MockGitDir gitDir = MockGitDir(); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + final MockProcessResult mockProcessResult = MockProcessResult(); + if (invocation.positionalArguments[0][0] == 'diff') { + when(mockProcessResult.stdout as String?) + .thenReturn(gitDiffResponse); + } + return Future.value(mockProcessResult); + }); + + return TestPackageLoopingCommand( + packagesDir, + platform: mockPlatform, + hasLongOutput: hasLongOutput, + includeSubpackages: includeSubpackages, + failsDuringInit: failsDuringInit, + warnsDuringInit: warnsDuringInit, + warnsDuringCleanup: warnsDuringCleanup, + customFailureListHeader: customFailureListHeader, + customFailureListFooter: customFailureListFooter, + captureOutput: captureOutput, + gitDir: gitDir, + ); + } + + /// Runs [command] with the given [arguments], and returns its output. + Future> runCommand( + TestPackageLoopingCommand command, { + List arguments = const [], + void Function(Error error)? errorHandler, + }) async { + late CommandRunner runner; + runner = CommandRunner('test_package_looping_command', + 'Test for base package looping functionality'); + runner.addCommand(command); + return await runCapturingPrint( + runner, + [command.name, ...arguments], + errorHandler: errorHandler, + ); + } + + group('tool exit', () { + test('is handled during initializeRun', () async { + final TestPackageLoopingCommand command = + createTestCommand(failsDuringInit: true); + + expect(() => runCommand(command), throwsA(isA())); + }); + + test('does not stop looping on error', () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + failingPackage.childFile(_errorFile).createSync(); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + '${_startHeadingColor}Running for package_c...$_endColor', + ])); + }); + + 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', () { + test('includes plugins and packages', () async { + final Directory plugin = createFakePlugin('a_plugin', packagesDir); + final Directory package = createFakePackage('a_package', packagesDir); + + final TestPackageLoopingCommand command = createTestCommand(); + await runCommand(command); + + expect(command.checkedPackages, + unorderedEquals([plugin.path, package.path])); + }); + + test('includes third_party/packages', () async { + final Directory package1 = createFakePackage('a_package', packagesDir); + final Directory package2 = + createFakePackage('another_package', thirdPartyPackagesDir); + + final TestPackageLoopingCommand command = createTestCommand(); + await runCommand(command); + + expect(command.checkedPackages, + unorderedEquals([package1.path, package2.path])); + }); + + test('includes subpackages when requested', () async { + final Directory plugin = createFakePlugin('a_plugin', packagesDir, + examples: ['example1', 'example2']); + final Directory package = createFakePackage('a_package', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(includeSubpackages: true); + await runCommand(command); + + expect( + command.checkedPackages, + unorderedEquals([ + plugin.path, + plugin.childDirectory('example').childDirectory('example1').path, + plugin.childDirectory('example').childDirectory('example2').path, + package.path, + package.childDirectory('example').path, + ])); + }); + + 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', () { + test('has the expected package headers for long-form output', () async { + createFakePlugin('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: true); + final List output = await runCommand(command); + + const String separator = + '============================================================'; + expect( + output, + containsAllInOrder([ + '$_startHeadingColor\n$separator\n|| Running for package_a\n$separator\n$_endColor', + '$_startHeadingColor\n$separator\n|| Running for package_b\n$separator\n$_endColor', + ])); + }); + + test('has the expected package headers for short-form output', () async { + createFakePlugin('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + ])); + }); + + test('shows the success message when nothing fails', () async { + createFakePackage('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + }); + + test('shows failure summaries when something fails without extra details', + () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage1 = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + final Directory failingPackage2 = + createFakePlugin('package_d', packagesDir); + failingPackage1.childFile(_errorFile).createSync(); + failingPackage2.childFile(_errorFile).createSync(); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '\n', + '${_startErrorColor}The following packages had errors:$_endColor', + '$_startErrorColor package_b$_endColor', + '$_startErrorColor package_d$_endColor', + '${_startErrorColor}See above for full details.$_endColor', + ])); + }); + + test('uses custom summary header and footer if provided', () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage1 = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + final Directory failingPackage2 = + createFakePlugin('package_d', packagesDir); + failingPackage1.childFile(_errorFile).createSync(); + failingPackage2.childFile(_errorFile).createSync(); + + final TestPackageLoopingCommand command = createTestCommand( + hasLongOutput: false, + customFailureListHeader: 'This is a custom header', + customFailureListFooter: 'And a custom footer!'); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '\n', + '${_startErrorColor}This is a custom header$_endColor', + '$_startErrorColor package_b$_endColor', + '$_startErrorColor package_d$_endColor', + '${_startErrorColor}And a custom footer!$_endColor', + ])); + }); + + test('shows failure summaries when something fails with extra details', + () async { + createFakePackage('package_a', packagesDir); + final Directory failingPackage1 = + createFakePlugin('package_b', packagesDir); + createFakePackage('package_c', packagesDir); + final Directory failingPackage2 = + createFakePlugin('package_d', packagesDir); + final File errorFile1 = failingPackage1.childFile(_errorFile); + errorFile1.createSync(); + errorFile1.writeAsStringSync('just one detail'); + final File errorFile2 = failingPackage2.childFile(_errorFile); + errorFile2.createSync(); + errorFile2.writeAsStringSync('first detail\nsecond detail'); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + Error? commandError; + final List output = + await runCommand(command, errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + '\n', + '${_startErrorColor}The following packages had errors:$_endColor', + '$_startErrorColor package_b:\n just one detail$_endColor', + '$_startErrorColor package_d:\n first detail\n second detail$_endColor', + '${_startErrorColor}See above for full details.$_endColor', + ])); + }); + + test('is captured, not printed, when requested', () async { + createFakePlugin('package_a', packagesDir); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: true, captureOutput: true); + final List output = await runCommand(command); + + expect(output, isEmpty); + + // None of the output should be colorized when captured. + const String separator = + '============================================================'; + expect( + command.capturedOutput, + containsAllInOrder([ + '\n$separator\n|| Running for package_a\n$separator\n', + '\n$separator\n|| Running for package_b\n$separator\n', + 'No issues found!', + ])); + }); + + test('logs skips', () async { + createFakePackage('package_a', packagesDir); + final Directory skipPackage = createFakePackage('package_b', packagesDir); + skipPackage.childFile(_skipFile).writeAsStringSync('For a reason'); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + '$_startSkipColor SKIPPING: For a reason$_endColor', + ])); + }); + + 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 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_b', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startWarningColor}Warning 1$_endColor', + '${_startWarningColor}Warning 2$_endColor', + '${_startHeadingColor}Running for package_b...$_endColor', + ])); + }); + + test('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); + warnPackage1 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_b', packagesDir); + final Directory skipPackage = createFakePackage('package_c', packagesDir); + skipPackage.childFile(_skipFile).writeAsStringSync('For a reason'); + final Directory skipAndWarnPackage = + createFakePackage('package_d', packagesDir); + skipAndWarnPackage.childFile(_warningFile).writeAsStringSync('Warning'); + skipAndWarnPackage.childFile(_skipFile).writeAsStringSync('See warning'); + final Directory warnPackage2 = + createFakePackage('package_e', packagesDir); + warnPackage2 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_f', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: false); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '------------------------------------------------------------', + 'Ran for 4 package(s) (2 with warnings)', + 'Skipped 2 package(s) (1 with warnings)', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + // The long-form summary should not be printed for short-form commands. + expect(output, isNot(contains('Run summary:'))); + expect(output, isNot(contains(contains('package a - ran')))); + }); + + 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); + warnPackage1 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_b', packagesDir); + final Directory skipPackage = createFakePackage('package_c', packagesDir); + skipPackage.childFile(_skipFile).writeAsStringSync('For a reason'); + final Directory skipAndWarnPackage = + createFakePackage('package_d', packagesDir); + skipAndWarnPackage.childFile(_warningFile).writeAsStringSync('Warning'); + skipAndWarnPackage.childFile(_skipFile).writeAsStringSync('See warning'); + final Directory warnPackage2 = + createFakePackage('package_e', packagesDir); + warnPackage2 + .childFile(_warningFile) + .writeAsStringSync('Warning 1\nWarning 2'); + createFakePackage('package_f', packagesDir); + + final TestPackageLoopingCommand command = + createTestCommand(hasLongOutput: true); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '------------------------------------------------------------', + 'Run overview:', + ' package_a - ${_startWarningColor}ran (with warning)$_endColor', + ' package_b - ${_startSuccessColor}ran$_endColor', + ' package_c - ${_startSkipColor}skipped$_endColor', + ' package_d - ${_startSkipWithWarningColor}skipped (with warning)$_endColor', + ' package_e - ${_startWarningColor}ran (with warning)$_endColor', + ' package_f - ${_startSuccessColor}ran$_endColor', + '', + 'Ran for 4 package(s) (2 with warnings)', + 'Skipped 2 package(s) (1 with warnings)', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + }); + + test('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); + + final TestPackageLoopingCommand command = createTestCommand( + hasLongOutput: false, + warnsDuringCleanup: true, + warnsDuringInit: true, + ); + final List output = await runCommand(command); + + expect( + output, + containsAllInOrder([ + '${_startWarningColor}Warning during initializeRun$_endColor', + '${_startHeadingColor}Running for package_a...$_endColor', + '${_startWarningColor}Warning during completeRun$_endColor', + '------------------------------------------------------------', + 'Ran for 1 package(s)', + '2 warnings not associated with a package', + '\n', + '${_startSuccessColor}No issues found!$_endColor', + ])); + }); + }); +} + +class TestPackageLoopingCommand extends PackageLoopingCommand { + TestPackageLoopingCommand( + Directory packagesDir, { + required Platform platform, + this.hasLongOutput = true, + this.includeSubpackages = false, + this.customFailureListHeader, + this.customFailureListFooter, + this.failsDuringInit = false, + this.warnsDuringInit = false, + this.warnsDuringCleanup = false, + this.captureOutput = false, + ProcessRunner processRunner = const ProcessRunner(), + GitDir? gitDir, + }) : super(packagesDir, + processRunner: processRunner, platform: platform, gitDir: gitDir); + + final List checkedPackages = []; + final List capturedOutput = []; + + final String? customFailureListHeader; + final String? customFailureListFooter; + + final bool failsDuringInit; + final bool warnsDuringInit; + final bool warnsDuringCleanup; + + @override + bool hasLongOutput; + + @override + bool includeSubpackages; + + @override + String get failureListHeader => + customFailureListHeader ?? super.failureListHeader; + + @override + String get failureListFooter => + customFailureListFooter ?? super.failureListFooter; + + @override + bool captureOutput; + + @override + final String name = 'loop-test'; + + @override + final String description = 'sample package looping command'; + + @override + Future initializeRun() async { + if (warnsDuringInit) { + logWarning('Warning during initializeRun'); + } + if (failsDuringInit) { + throw ToolExit(2); + } + } + + @override + Future runForPackage(RepositoryPackage package) async { + checkedPackages.add(package.path); + final File warningFile = package.directory.childFile(_warningFile); + if (warningFile.existsSync()) { + final List warnings = warningFile.readAsLinesSync(); + warnings.forEach(logWarning); + } + final File skipFile = package.directory.childFile(_skipFile); + if (skipFile.existsSync()) { + return PackageResult.skip(skipFile.readAsStringSync()); + } + 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(); + } + + @override + Future completeRun() async { + if (warnsDuringInit) { + logWarning('Warning during completeRun'); + } + } + + @override + Future handleCapturedOutput(List output) async { + capturedOutput.addAll(output); + } +} + +class MockProcessResult extends Mock implements io.ProcessResult {} diff --git a/script/tool/test/common/plugin_command_test.dart b/script/tool/test/common/plugin_command_test.dart new file mode 100644 index 000000000000..13724e26e5f8 --- /dev/null +++ b/script/tool/test/common/plugin_command_test.dart @@ -0,0 +1,763 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package: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'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:platform/platform.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; +import '../util.dart'; +import 'plugin_command_test.mocks.dart'; + +@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; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + thirdPartyPackagesDir = packagesDir.parent + .childDirectory('third_party') + .childDirectory('packages'); + + final MockGitDir gitDir = MockGitDir(); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + final List arguments = + invocation.positionalArguments[0]! as List; + // Attach the first argument to the command to make targeting the mock + // results easier. + final String gitCommand = arguments.removeAt(0); + return processRunner.run('git-$gitCommand', arguments); + }); + processRunner = RecordingProcessRunner(); + command = SamplePluginCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + gitDir: gitDir, + ); + runner = + CommandRunner('common_command', 'Test for common functionality'); + runner.addCommand(command); + }); + + group('plugin iteration', () { + test('all plugins from file system', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runCapturingPrint(runner, ['sample']); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('includes both plugins and packages', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + final Directory package3 = createFakePackage('package3', packagesDir); + final Directory package4 = createFakePackage('package4', packagesDir); + await runCapturingPrint(runner, ['sample']); + expect( + command.plugins, + unorderedEquals([ + plugin1.path, + plugin2.path, + package3.path, + package4.path, + ])); + }); + + test('all plugins includes third_party/packages', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + final Directory plugin3 = + createFakePlugin('plugin3', thirdPartyPackagesDir); + await runCapturingPrint(runner, ['sample']); + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path, plugin3.path])); + }); + + test('--packages limits packages', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + createFakePackage('package3', packagesDir); + final Directory package4 = createFakePackage('package4', packagesDir); + await runCapturingPrint( + runner, ['sample', '--packages=plugin1,package4']); + expect( + command.plugins, + unorderedEquals([ + plugin1.path, + package4.path, + ])); + }); + + test('--plugins acts as an alias to --packages', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + createFakePackage('package3', packagesDir); + final Directory package4 = createFakePackage('package4', packagesDir); + await runCapturingPrint( + runner, ['sample', '--plugins=plugin1,package4']); + expect( + command.plugins, + unorderedEquals([ + plugin1.path, + package4.path, + ])); + }); + + test('exclude packages when packages flag is specified', () async { + createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runCapturingPrint(runner, [ + 'sample', + '--packages=plugin1,plugin2', + '--exclude=plugin1' + ]); + expect(command.plugins, unorderedEquals([plugin2.path])); + }); + + test('exclude packages when packages flag isn\'t specified', () async { + createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + await runCapturingPrint( + runner, ['sample', '--exclude=plugin1,plugin2']); + expect(command.plugins, unorderedEquals([])); + }); + + test('exclude federated plugins when packages flag is specified', () async { + createFakePlugin('plugin1', packagesDir.childDirectory('federated')); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runCapturingPrint(runner, [ + 'sample', + '--packages=federated/plugin1,plugin2', + '--exclude=federated/plugin1' + ]); + expect(command.plugins, unorderedEquals([plugin2.path])); + }); + + test('exclude entire federated plugins when packages flag is specified', + () async { + createFakePlugin('plugin1', packagesDir.childDirectory('federated')); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runCapturingPrint(runner, [ + 'sample', + '--packages=federated/plugin1,plugin2', + '--exclude=federated' + ]); + 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', () { + test('all plugins should be tested if there are no changes.', () async { + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runCapturingPrint(runner, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test( + 'all plugins should be tested if there are no plugin related changes.', + () async { + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: 'AUTHORS'), + ]; + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + final Directory plugin2 = createFakePlugin('plugin2', packagesDir); + await runCapturingPrint(runner, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('all plugins should be tested if .cirrus.yml changes.', () async { + 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, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('all plugins should be tested if .ci.yaml changes', () async { + 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, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('all plugins should be tested if anything in .ci/ changes', + () async { + 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, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('all plugins should be tested if anything in script changes.', + () async { + 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, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('all plugins should be tested if the root analysis options change.', + () async { + 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, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('all plugins should be tested if formatting options change.', + () async { + 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, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test('Only changed plugin should be tested.', () async { + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: 'packages/plugin1/plugin1.dart'), + ]; + final Directory plugin1 = createFakePlugin('plugin1', packagesDir); + createFakePlugin('plugin2', packagesDir); + final List output = await runCapturingPrint(runner, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect( + output, + containsAllInOrder([ + contains( + 'Running for all packages that have changed relative to "master"'), + ])); + + expect(command.plugins, unorderedEquals([plugin1.path])); + }); + + test('multiple files in one plugin should also test the plugin', + () async { + 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, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, unorderedEquals([plugin1.path])); + }); + + test('multiple plugins changed should test all the changed plugins', + () async { + 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); + await runCapturingPrint(runner, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, + unorderedEquals([plugin1.path, plugin2.path])); + }); + + test( + 'multiple plugins inside the same plugin group changed should output the plugin group name', + () async { + 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); + createFakePlugin('plugin3', packagesDir); + await runCapturingPrint(runner, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect(command.plugins, unorderedEquals([plugin1.path])); + }); + + test( + 'changing one plugin in a federated group should include all plugins in the group', + () async { + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: ''' +packages/plugin1/plugin1/plugin1.dart +'''), + ]; + final Directory plugin1 = + createFakePlugin('plugin1', packagesDir.childDirectory('plugin1')); + final Directory plugin2 = createFakePlugin('plugin1_platform_interface', + packagesDir.childDirectory('plugin1')); + final Directory plugin3 = createFakePlugin( + 'plugin1_web', packagesDir.childDirectory('plugin1')); + await runCapturingPrint(runner, [ + 'sample', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + expect( + command.plugins, + unorderedEquals( + [plugin1.path, plugin2.path, plugin3.path])); + }); + + test('--exclude flag works with --run-on-changed-packages', () async { + 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); + createFakePlugin('plugin3', packagesDir); + await runCapturingPrint(runner, [ + 'sample', + '--exclude=plugin2,plugin3', + '--base-sha=master', + '--run-on-changed-packages' + ]); + + 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( + Directory packagesDir, { + ProcessRunner processRunner = const ProcessRunner(), + Platform platform = const LocalPlatform(), + GitDir? gitDir, + }) : super(packagesDir, + processRunner: processRunner, platform: platform, gitDir: gitDir); + + final List plugins = []; + + @override + final String name = 'sample'; + + @override + final String description = 'sample command'; + + @override + Future run() async { + await for (final PackageEnumerationEntry entry in getTargetPackages()) { + plugins.add(entry.package.path); + } + } +} diff --git a/script/tool/test/common/plugin_command_test.mocks.dart b/script/tool/test/common/plugin_command_test.mocks.dart new file mode 100644 index 000000000000..b7f7807b3b05 --- /dev/null +++ b/script/tool/test/common/plugin_command_test.mocks.dart @@ -0,0 +1,143 @@ +// Mocks generated by Mockito 5.0.7 from annotations +// in flutter_plugin_tools/test/common_test.dart. +// Do not manually edit this file. + +import 'dart:async' as _i6; +import 'dart:io' as _i4; + +import 'package:git/src/branch_reference.dart' as _i3; +import 'package:git/src/commit.dart' as _i2; +import 'package:git/src/commit_reference.dart' as _i8; +import 'package:git/src/git_dir.dart' as _i5; +import 'package:git/src/tag.dart' as _i7; +import 'package:git/src/tree_entry.dart' as _i9; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: comment_references +// ignore_for_file: unnecessary_parenthesis + +// ignore_for_file: prefer_const_constructors + +// ignore_for_file: avoid_redundant_argument_values + +class _FakeCommit extends _i1.Fake implements _i2.Commit {} + +class _FakeBranchReference extends _i1.Fake implements _i3.BranchReference {} + +class _FakeProcessResult extends _i1.Fake implements _i4.ProcessResult {} + +/// A class which mocks [GitDir]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockGitDir extends _i1.Mock implements _i5.GitDir { + MockGitDir() { + _i1.throwOnMissingStub(this); + } + + @override + String get path => + (super.noSuchMethod(Invocation.getter(#path), returnValue: '') as String); + @override + _i6.Future commitCount([String? branchName = r'HEAD']) => + (super.noSuchMethod(Invocation.method(#commitCount, [branchName]), + returnValue: Future.value(0)) as _i6.Future); + @override + _i6.Future<_i2.Commit> commitFromRevision(String? revision) => + (super.noSuchMethod(Invocation.method(#commitFromRevision, [revision]), + returnValue: Future<_i2.Commit>.value(_FakeCommit())) + as _i6.Future<_i2.Commit>); + @override + _i6.Future> commits([String? branchName = r'HEAD']) => + (super.noSuchMethod(Invocation.method(#commits, [branchName]), + returnValue: + Future>.value({})) + as _i6.Future>); + @override + _i6.Future<_i3.BranchReference?> branchReference(String? branchName) => + (super.noSuchMethod(Invocation.method(#branchReference, [branchName]), + returnValue: + Future<_i3.BranchReference?>.value(_FakeBranchReference())) + as _i6.Future<_i3.BranchReference?>); + @override + _i6.Future> branches() => (super.noSuchMethod( + Invocation.method(#branches, []), + returnValue: + Future>.value(<_i3.BranchReference>[])) + as _i6.Future>); + @override + _i6.Stream<_i7.Tag> tags() => + (super.noSuchMethod(Invocation.method(#tags, []), + returnValue: Stream<_i7.Tag>.empty()) as _i6.Stream<_i7.Tag>); + @override + _i6.Future> showRef( + {bool? heads = false, bool? tags = false}) => + (super.noSuchMethod( + Invocation.method(#showRef, [], {#heads: heads, #tags: tags}), + returnValue: Future>.value( + <_i8.CommitReference>[])) + as _i6.Future>); + @override + _i6.Future<_i3.BranchReference> currentBranch() => + (super.noSuchMethod(Invocation.method(#currentBranch, []), + returnValue: + Future<_i3.BranchReference>.value(_FakeBranchReference())) + as _i6.Future<_i3.BranchReference>); + @override + _i6.Future> lsTree(String? treeish, + {bool? subTreesOnly = false, String? path}) => + (super.noSuchMethod( + Invocation.method(#lsTree, [treeish], + {#subTreesOnly: subTreesOnly, #path: path}), + returnValue: Future>.value(<_i9.TreeEntry>[])) + as _i6.Future>); + @override + _i6.Future createOrUpdateBranch( + String? branchName, String? treeSha, String? commitMessage) => + (super.noSuchMethod( + Invocation.method( + #createOrUpdateBranch, [branchName, treeSha, commitMessage]), + returnValue: Future.value('')) as _i6.Future); + @override + _i6.Future commitTree(String? treeSha, String? commitMessage, + {List? parentCommitShas}) => + (super.noSuchMethod( + Invocation.method(#commitTree, [treeSha, commitMessage], + {#parentCommitShas: parentCommitShas}), + returnValue: Future.value('')) as _i6.Future); + @override + _i6.Future> writeObjects(List? paths) => + (super.noSuchMethod(Invocation.method(#writeObjects, [paths]), + returnValue: + Future>.value({})) + as _i6.Future>); + @override + _i6.Future<_i4.ProcessResult> runCommand(Iterable? args, + {bool? throwOnError = true}) => + (super.noSuchMethod( + Invocation.method(#runCommand, [args], {#throwOnError: throwOnError}), + returnValue: + Future<_i4.ProcessResult>.value(_FakeProcessResult())) as _i6 + .Future<_i4.ProcessResult>); + @override + _i6.Future isWorkingTreeClean() => + (super.noSuchMethod(Invocation.method(#isWorkingTreeClean, []), + returnValue: Future.value(false)) as _i6.Future); + @override + _i6.Future<_i2.Commit?> updateBranch( + String? branchName, + _i6.Future Function(_i4.Directory)? populater, + String? commitMessage) => + (super.noSuchMethod( + Invocation.method( + #updateBranch, [branchName, populater, commitMessage]), + returnValue: Future<_i2.Commit?>.value(_FakeCommit())) + as _i6.Future<_i2.Commit?>); + @override + _i6.Future<_i2.Commit?> updateBranchWithDirectoryContents(String? branchName, + String? sourceDirectoryPath, String? commitMessage) => + (super.noSuchMethod( + Invocation.method(#updateBranchWithDirectoryContents, + [branchName, sourceDirectoryPath, commitMessage]), + returnValue: Future<_i2.Commit?>.value(_FakeCommit())) + as _i6.Future<_i2.Commit?>); +} diff --git a/script/tool/test/common/plugin_utils_test.dart b/script/tool/test/common/plugin_utils_test.dart new file mode 100644 index 000000000000..ac619e2622e0 --- /dev/null +++ b/script/tool/test/common/plugin_utils_test.dart @@ -0,0 +1,344 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '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'; + +void main() { + late FileSystem fileSystem; + late Directory packagesDir; + + setUp(() { + fileSystem = MemoryFileSystem(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + }); + + group('pluginSupportsPlatform', () { + test('no platforms', () async { + final RepositoryPackage plugin = + RepositoryPackage(createFakePlugin('plugin', packagesDir)); + + expect(pluginSupportsPlatform(kPlatformAndroid, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformIos, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformLinux, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformMacos, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformWeb, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformWindows, plugin), isFalse); + }); + + test('all platforms', () async { + final 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); + expect(pluginSupportsPlatform(kPlatformLinux, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformMacos, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformWeb, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformWindows, plugin), isTrue); + }); + + test('some platforms', () async { + final 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); + expect(pluginSupportsPlatform(kPlatformLinux, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformMacos, plugin), isFalse); + expect(pluginSupportsPlatform(kPlatformWeb, plugin), isTrue); + expect(pluginSupportsPlatform(kPlatformWindows, plugin), isFalse); + }); + + test('inline plugins are only detected as inline', () async { + 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, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform(kPlatformAndroid, plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform(kPlatformIos, plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform(kPlatformIos, plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform(kPlatformLinux, plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform(kPlatformLinux, plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform(kPlatformMacos, plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform(kPlatformMacos, plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform(kPlatformWeb, plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform(kPlatformWeb, plugin, + requiredMode: PlatformSupport.federated), + isFalse); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + requiredMode: PlatformSupport.inline), + isTrue); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + requiredMode: PlatformSupport.federated), + isFalse); + }); + + test('federated plugins are only detected as federated', () async { + 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, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform(kPlatformAndroid, plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform(kPlatformIos, plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform(kPlatformIos, plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform(kPlatformLinux, plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform(kPlatformLinux, plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform(kPlatformMacos, plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform(kPlatformMacos, plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform(kPlatformWeb, plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform(kPlatformWeb, plugin, + requiredMode: PlatformSupport.inline), + isFalse); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + requiredMode: PlatformSupport.federated), + isTrue); + expect( + pluginSupportsPlatform(kPlatformWindows, plugin, + 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 new file mode 100644 index 000000000000..1692cf214abe --- /dev/null +++ b/script/tool/test/common/pub_version_finder_test.dart @@ -0,0 +1,89 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_plugin_tools/src/common/pub_version_finder.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +void main() { + test('Package does not exist.', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 404); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(packageName: 'some_package'); + + expect(response.versions, isEmpty); + expect(response.result, PubVersionFinderResult.noPackageFound); + expect(response.httpResponse.statusCode, 404); + expect(response.httpResponse.body, ''); + }); + + test('HTTP error when getting versions from pub', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 400); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(packageName: 'some_package'); + + expect(response.versions, isEmpty); + expect(response.result, PubVersionFinderResult.fail); + expect(response.httpResponse.statusCode, 400); + expect(response.httpResponse.body, ''); + }); + + test('Get a correct list of versions when http response is OK.', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + '0.0.2+2', + '0.1.1', + '0.0.1+1', + '0.1.0', + '0.2.0', + '0.1.0+1', + '0.0.2+1', + '2.0.0', + '1.2.0', + '1.0.0', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(packageName: 'some_package'); + + expect(response.versions, [ + Version.parse('2.0.0'), + Version.parse('1.2.0'), + Version.parse('1.0.0'), + Version.parse('0.2.0'), + Version.parse('0.1.1'), + Version.parse('0.1.0+1'), + Version.parse('0.1.0'), + Version.parse('0.0.2+2'), + Version.parse('0.0.2+1'), + Version.parse('0.0.2'), + Version.parse('0.0.1+1'), + Version.parse('0.0.1'), + ]); + expect(response.result, PubVersionFinderResult.success); + expect(response.httpResponse.statusCode, 200); + expect(response.httpResponse.body, json.encode(httpResponse)); + }); +} + +class MockProcessResult extends Mock implements ProcessResult {} diff --git a/script/tool/test/common/repository_package_test.dart b/script/tool/test/common/repository_package_test.dart new file mode 100644 index 000000000000..4c20389ae4be --- /dev/null +++ b/script/tool/test/common/repository_package_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 '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); + }); + }); + + group('federated plugin queries', () { + test('all return false for a simple plugin', () { + final Directory plugin = createFakePlugin('a_plugin', packagesDir); + expect(RepositoryPackage(plugin).isFederated, false); + expect(RepositoryPackage(plugin).isPlatformInterface, false); + expect(RepositoryPackage(plugin).isFederated, false); + }); + + test('handle app-facing packages', () { + final Directory plugin = + createFakePlugin('a_plugin', packagesDir.childDirectory('a_plugin')); + expect(RepositoryPackage(plugin).isFederated, true); + expect(RepositoryPackage(plugin).isPlatformInterface, false); + expect(RepositoryPackage(plugin).isPlatformImplementation, false); + }); + + test('handle platform interface packages', () { + final Directory plugin = createFakePlugin('a_plugin_platform_interface', + packagesDir.childDirectory('a_plugin')); + expect(RepositoryPackage(plugin).isFederated, true); + expect(RepositoryPackage(plugin).isPlatformInterface, true); + expect(RepositoryPackage(plugin).isPlatformImplementation, false); + }); + + test('handle platform implementation packages', () { + // A platform interface can end with anything, not just one of the known + // platform names, because of cases like webview_flutter_wkwebview. + final Directory plugin = createFakePlugin( + 'a_plugin_foo', packagesDir.childDirectory('a_plugin')); + expect(RepositoryPackage(plugin).isFederated, true); + expect(RepositoryPackage(plugin).isPlatformInterface, false); + expect(RepositoryPackage(plugin).isPlatformImplementation, true); + }); + }); +} diff --git a/script/tool/test/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/federation_safety_check_command_test.dart b/script/tool/test/federation_safety_check_command_test.dart new file mode 100644 index 000000000000..e23485fbc8b7 --- /dev/null +++ b/script/tool/test/federation_safety_check_command_test.dart @@ -0,0 +1,355 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; + +import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_plugin_tools/src/common/core.dart'; +import 'package:flutter_plugin_tools/src/common/repository_package.dart'; +import 'package:flutter_plugin_tools/src/federation_safety_check_command.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'common/plugin_command_test.mocks.dart'; +import 'mocks.dart'; +import 'util.dart'; + +void main() { + FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + + final MockGitDir gitDir = MockGitDir(); + when(gitDir.path).thenReturn(packagesDir.parent.path); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + final List arguments = + invocation.positionalArguments[0]! as List; + // Route git calls through the process runner, to make mock output + // consistent with other processes. Attach the first argument to the + // command to make targeting the mock results easier. + final String gitCommand = arguments.removeAt(0); + return processRunner.run('git-$gitCommand', arguments); + }); + + processRunner = RecordingProcessRunner(); + final FederationSafetyCheckCommand command = FederationSafetyCheckCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + gitDir: gitDir); + + runner = CommandRunner('federation_safety_check_command', + 'Test for $FederationSafetyCheckCommand'); + runner.addCommand(command); + }); + + test('skips non-plugin packages', () async { + final Directory package = createFakePackage('foo', packagesDir); + + final String changedFileOutput = [ + package.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo...'), + contains('Not a plugin'), + contains('Skipped 1 package(s)'), + ]), + ); + }); + + test('skips unfederated plugins', () async { + final Directory package = createFakePlugin('foo', packagesDir); + + final String changedFileOutput = [ + package.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo...'), + contains('Not a federated plugin'), + contains('Skipped 1 package(s)'), + ]), + ); + }); + + test('skips interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + platformInterface.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo_platform_interface...'), + contains('Platform interface changes are not validated.'), + contains('Skipped 1 package(s)'), + ]), + ); + }); + + test('allows changes to just an interface package', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + createFakePlugin('foo', pluginGroupDir); + createFakePlugin('foo_ios', pluginGroupDir); + createFakePlugin('foo_android', pluginGroupDir); + + final String changedFileOutput = [ + platformInterface.childDirectory('lib').childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No Dart changes.'), + contains('Running for foo_android...'), + contains('No Dart changes.'), + contains('Running for foo_ios...'), + contains('No Dart changes.'), + contains('Running for foo_platform_interface...'), + contains('Ran for 3 package(s)'), + contains('Skipped 1 package(s)'), + ]), + ); + expect( + output, + isNot(contains([ + contains('No published changes for foo_platform_interface'), + ])), + ); + }); + + test('allows changes to multiple non-interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No published changes for foo_platform_interface.'), + contains('Running for foo_bar...'), + contains('No published changes for foo_platform_interface.'), + ]), + ); + }); + + test( + 'fails on changes to interface and non-interface packages in the same plugin', + () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['federation-safety-check'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('Dart changes are not allowed to other packages in foo in the ' + 'same PR as changes to public Dart code in foo_platform_interface, ' + 'as this can cause accidental breaking changes to be missed by ' + 'automated checks. Please split the changes to these two packages ' + 'into separate PRs.'), + contains('Running for foo_bar...'), + contains('Dart changes are not allowed to other packages in foo'), + contains('The following packages had errors:'), + contains('foo/foo:\n' + ' foo_platform_interface changed.'), + contains('foo_bar:\n' + ' foo_platform_interface changed.'), + ]), + ); + }); + + test('ignores test-only changes to interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('test').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No public code changes for foo_platform_interface.'), + contains('Running for foo_bar...'), + contains('No public code changes for foo_platform_interface.'), + ]), + ); + }); + + test('ignores unpublished changes to interface packages', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('lib').childFile('foo.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + // Simulate no change to the version in the interface's pubspec.yaml. + processRunner.mockProcessesForExecutable['git-show'] = [ + MockProcess( + stdout: RepositoryPackage(platformInterface) + .pubspecFile + .readAsStringSync()), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains('No published changes for foo_platform_interface.'), + contains('Running for foo_bar...'), + contains('No published changes for foo_platform_interface.'), + ]), + ); + }); + + test('allows things that look like mass changes, with warning', () async { + final Directory pluginGroupDir = packagesDir.childDirectory('foo'); + final Directory appFacing = createFakePlugin('foo', pluginGroupDir); + final Directory implementation = + createFakePlugin('foo_bar', pluginGroupDir); + final Directory platformInterface = + createFakePlugin('foo_platform_interface', pluginGroupDir); + + final Directory otherPlugin1 = createFakePlugin('bar', packagesDir); + final Directory otherPlugin2 = createFakePlugin('baz', packagesDir); + + final String changedFileOutput = [ + appFacing.childFile('foo.dart'), + implementation.childFile('foo.dart'), + platformInterface.childFile('pubspec.yaml'), + platformInterface.childDirectory('lib').childFile('foo.dart'), + otherPlugin1.childFile('bar.dart'), + otherPlugin2.childFile('baz.dart'), + ].map((File file) => file.path).join('\n'); + processRunner.mockProcessesForExecutable['git-diff'] = [ + MockProcess(stdout: changedFileOutput), + ]; + + final List output = + await runCapturingPrint(runner, ['federation-safety-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for foo/foo...'), + contains( + 'Ignoring potentially dangerous change, as this appears to be a mass change.'), + contains('Running for foo_bar...'), + contains( + 'Ignoring potentially dangerous change, as this appears to be a mass change.'), + contains('Ran for 2 package(s) (2 with warnings)'), + ]), + ); + }); +} diff --git a/script/tool/test/firebase_test_lab_command_test.dart b/script/tool/test/firebase_test_lab_command_test.dart new file mode 100644 index 000000000000..268210d00425 --- /dev/null +++ b/script/tool/test/firebase_test_lab_command_test.dart @@ -0,0 +1,611 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package: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/firebase_test_lab_command.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'util.dart'; + +void main() { + group('$FirebaseTestLabCommand', () { + FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); + final FirebaseTestLabCommand command = FirebaseTestLabCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); + + runner = CommandRunner( + 'firebase_test_lab_command', 'Test for $FirebaseTestLabCommand'); + runner.addCommand(command); + }); + + test('fails if gcloud auth fails', () async { + processRunner.mockProcessesForExecutable['gcloud'] = [ + MockProcess(exitCode: 1) + ]; + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['firebase-test-lab'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to activate gcloud account.'), + ])); + }); + + test('retries gcloud set', () async { + processRunner.mockProcessesForExecutable['gcloud'] = [ + MockProcess(), // auth + MockProcess(exitCode: 1), // config + ]; + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final List output = + await runCapturingPrint(runner, ['firebase-test-lab']); + + expect( + output, + containsAllInOrder([ + contains( + 'Warning: gcloud config set returned a non-zero exit code. Continuing anyway.'), + ])); + }); + + 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=redfin,version=30', + '--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-cirrus'.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=redfin,version=30 --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=redfin,version=30 --device model=seoul,version=26' + .split(' '), + '/packages/plugin2/example'), + ]), + ); + }); + + test('runs integration tests', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'test/plugin_test.dart', + 'example/integration_test/bar_test.dart', + 'example/integration_test/foo_test.dart', + 'example/integration_test/should_not_run.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final List output = await runCapturingPrint(runner, [ + 'firebase-test-lab', + '--device', + 'model=redfin,version=30', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Firebase project configured.'), + contains('Testing example/integration_test/bar_test.dart...'), + contains('Testing example/integration_test/foo_test.dart...'), + ]), + ); + expect(output, isNot(contains('test/plugin_test.dart'))); + expect(output, + isNot(contains('example/integration_test/should_not_run.dart'))); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'gcloud', + 'auth activate-service-account --key-file=${Platform.environment['HOME']}/gcloud-service-key.json' + .split(' '), + null), + ProcessCall( + 'gcloud', 'config set project flutter-cirrus'.split(' '), null), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleAndroidTest -Pverbose=true'.split(' '), + '/packages/plugin/example/android'), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/bar_test.dart' + .split(' '), + '/packages/plugin/example/android'), + ProcessCall( + 'gcloud', + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=redfin,version=30 --device model=seoul,version=26' + .split(' '), + '/packages/plugin/example'), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart' + .split(' '), + '/packages/plugin/example/android'), + ProcessCall( + 'gcloud', + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/1/ --device model=redfin,version=30 --device model=seoul,version=26' + .split(' '), + '/packages/plugin/example'), + ]), + ); + }); + + test('fails if a test fails', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/bar_test.dart', + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + processRunner.mockProcessesForExecutable['gcloud'] = [ + MockProcess(), // auth + MockProcess(), // config + MockProcess(exitCode: 1), // integration test #1 + MockProcess(), // integration test #2 + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=redfin,version=30', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Testing example/integration_test/bar_test.dart...'), + contains('Testing example/integration_test/foo_test.dart...'), + contains('plugin:\n' + ' example/integration_test/bar_test.dart failed tests'), + ]), + ); + }); + + test('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=redfin,version=30', + '--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=redfin,version=30', + '--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', + 'model=redfin,version=30', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for package'), + contains('package/example does not support Android'), + ]), + ); + expect(output, + isNot(contains('Testing example/integration_test/foo_test.dart...'))); + + expect( + processRunner.recordedCalls, + orderedEquals([]), + ); + }); + + test('builds if gradlew is missing', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final List output = await runCapturingPrint(runner, [ + 'firebase-test-lab', + '--device', + 'model=redfin,version=30', + '--device', + 'model=seoul,version=26', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + ]); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Running flutter build apk...'), + contains('Firebase project configured.'), + contains('Testing example/integration_test/foo_test.dart...'), + ]), + ); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 'flutter', + 'build apk'.split(' '), + '/packages/plugin/example/android', + ), + ProcessCall( + 'gcloud', + 'auth activate-service-account --key-file=${Platform.environment['HOME']}/gcloud-service-key.json' + .split(' '), + null), + ProcessCall( + 'gcloud', 'config set project flutter-cirrus'.split(' '), null), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleAndroidTest -Pverbose=true'.split(' '), + '/packages/plugin/example/android'), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart' + .split(' '), + '/packages/plugin/example/android'), + ProcessCall( + 'gcloud', + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=redfin,version=30 --device model=seoul,version=26' + .split(' '), + '/packages/plugin/example'), + ]), + ); + }); + + test('fails if building to generate gradlew fails', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + processRunner.mockProcessesForExecutable['flutter'] = [ + MockProcess(exitCode: 1) // flutter build + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=redfin,version=30', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to build example apk'), + ])); + }); + + test('fails if assembleAndroidTest fails', () async { + final Directory pluginDir = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess(exitCode: 1) + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=redfin,version=30', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to assemble androidTest'), + ])); + }); + + test('fails if assembleDebug fails', () async { + final Directory pluginDir = + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess(), // assembleAndroidTest + MockProcess(exitCode: 1), // assembleDebug + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, + [ + 'firebase-test-lab', + '--device', + 'model=redfin,version=30', + ], + errorHandler: (Error e) { + commandError = e; + }, + ); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Could not build example/integration_test/foo_test.dart'), + contains('The following packages had errors:'), + contains(' plugin:\n' + ' example/integration_test/foo_test.dart failed to build'), + ])); + }); + + test('experimental flag', () async { + createFakePlugin('plugin', packagesDir, extraFiles: [ + 'example/integration_test/foo_test.dart', + 'example/android/gradlew', + 'example/android/app/src/androidTest/MainActivityTest.java', + ]); + + await runCapturingPrint(runner, [ + 'firebase-test-lab', + '--device', + 'model=redfin,version=30', + '--test-run-id', + 'testRunId', + '--build-id', + 'buildId', + '--enable-experiment=exp1', + ]); + + 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-cirrus'.split(' '), null), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleAndroidTest -Pverbose=true -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' + .split(' '), + '/packages/plugin/example/android'), + ProcessCall( + '/packages/plugin/example/android/gradlew', + 'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1' + .split(' '), + '/packages/plugin/example/android'), + ProcessCall( + 'gcloud', + 'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 7m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/buildId/testRunId/0/ --device model=redfin,version=30' + .split(' '), + '/packages/plugin/example'), + ]), + ); + }); + }); +} diff --git a/script/tool/test/format_command_test.dart b/script/tool/test/format_command_test.dart new file mode 100644 index 000000000000..d278bb2940b8 --- /dev/null +++ b/script/tool/test/format_command_test.dart @@ -0,0 +1,627 @@ +// Copyright 2013 The Flutter 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/file_utils.dart'; +import 'package:flutter_plugin_tools/src/format_command.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'util.dart'; + +void main() { + late FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late RecordingProcessRunner processRunner; + late FormatCommand analyzeCommand; + late CommandRunner runner; + late String javaFormatPath; + + setUp(() { + fileSystem = MemoryFileSystem(); + mockPlatform = MockPlatform(); + packagesDir = createPackagesDirectory(fileSystem: fileSystem); + processRunner = RecordingProcessRunner(); + analyzeCommand = FormatCommand( + packagesDir, + processRunner: processRunner, + platform: mockPlatform, + ); + + // Create the java formatter file that the command checks for, to avoid + // a download. + 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); + + runner = CommandRunner('format_command', 'Test for format_command'); + runner.addCommand(analyzeCommand); + }); + + /// 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(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', + 'lib/src/b.dart', + 'lib/src/c.dart', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + ProcessCall( + 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), + ])); + }); + + test('fails if flutter format fails', () async { + const List files = [ + 'lib/a.dart', + 'lib/src/b.dart', + 'lib/src/c.dart', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] = + [MockProcess(exitCode: 1)]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Failed to format Dart files: exit code 1.'), + ])); + }); + + test('formats .java files', () async { + const List files = [ + 'android/src/main/java/io/flutter/plugins/a_plugin/a.java', + 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + const ProcessCall('java', ['-version'], null), + ProcessCall( + 'java', + [ + '-jar', + javaFormatPath, + '--replace', + ..._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', + 'android/src/main/java/io/flutter/plugins/a_plugin/b.java', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['java'] = [ + MockProcess(), // check for working java + MockProcess(exitCode: 1), // format + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Failed to format Java files: exit code 1.'), + ])); + }); + + test('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', + 'ios/Classes/Foo.m', + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + 'macos/Classes/Foo.mm', + 'windows/foo_plugin.cpp', + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: files, + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + const ProcessCall('clang-format', ['--version'], null), + ProcessCall( + 'clang-format', + [ + '-i', + '--style=Google', + ..._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), + ])); + }); + + test('fails if clang-format fails', () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['clang-format'] = [ + MockProcess(), // check for working clang-format + MockProcess(exitCode: 1), // format + ]; + Error? commandError; + final List output = await runCapturingPrint( + runner, ['format'], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains( + 'Failed to format C, C++, and Objective-C files: exit code 1.'), + ])); + }); + + test('skips known non-repo files', () async { + const List skipFiles = [ + '/example/build/SomeFramework.framework/Headers/SomeFramework.h', + '/example/Pods/APod.framework/Headers/APod.h', + '.dart_tool/internals/foo.cc', + '.dart_tool/internals/Bar.java', + '.dart_tool/internals/baz.dart', + ]; + const List clangFiles = ['ios/Classes/Foo.h']; + const List dartFiles = ['lib/a.dart']; + const List javaFiles = [ + 'android/src/main/java/io/flutter/plugins/a_plugin/a.java' + ]; + final Directory pluginDir = createFakePlugin( + 'a_plugin', + packagesDir, + extraFiles: [ + ...skipFiles, + // Include some files that should be formatted to validate that it's + // correctly filtering even when running the commands. + ...clangFiles, + ...dartFiles, + ...javaFiles, + ], + ); + + await runCapturingPrint(runner, ['format']); + + expect( + processRunner.recordedCalls, + containsAll([ + ProcessCall( + 'clang-format', + [ + '-i', + '--style=Google', + ..._getPackagesDirRelativePaths(pluginDir, clangFiles) + ], + packagesDir.path), + ProcessCall( + getFlutterCommand(mockPlatform), + [ + 'format', + ..._getPackagesDirRelativePaths(pluginDir, dartFiles) + ], + packagesDir.path), + ProcessCall( + 'java', + [ + '-jar', + javaFormatPath, + '--replace', + ..._getPackagesDirRelativePaths(pluginDir, javaFiles) + ], + packagesDir.path), + ])); + }); + + test('fails if files are changed with --file-on-change', () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; + processRunner.mockProcessesForExecutable['git'] = [ + MockProcess(stdout: changedFilePath), + ]; + + Error? commandError; + final List output = + await runCapturingPrint(runner, ['format', '--fail-on-change'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('These files are not formatted correctly'), + contains(changedFilePath), + contains('patch -p1 < files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + processRunner.mockProcessesForExecutable['git'] = [ + MockProcess(exitCode: 1) + ]; + Error? commandError; + final List output = + await runCapturingPrint(runner, ['format', '--fail-on-change'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to determine changed files.'), + ])); + }); + + test('reports git diff failures', () async { + const List files = [ + 'linux/foo_plugin.cc', + 'macos/Classes/Foo.h', + ]; + createFakePlugin('a_plugin', packagesDir, extraFiles: files); + + const String changedFilePath = 'packages/a_plugin/linux/foo_plugin.cc'; + processRunner.mockProcessesForExecutable['git'] = [ + MockProcess(stdout: changedFilePath), // ls-files + MockProcess(exitCode: 1), // diff + ]; + + Error? commandError; + final List output = + await runCapturingPrint(runner, ['format', '--fail-on-change'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('These files are not formatted correctly'), + contains(changedFilePath), + contains('Unable to determine diff.'), + ])); + }); + + 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/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/native_test_command_test.dart b/script/tool/test/native_test_command_test.dart new file mode 100644 index 000000000000..ba93efcb3ace --- /dev/null +++ b/script/tool/test/native_test_command_test.dart @@ -0,0 +1,1668 @@ +// Copyright 2013 The Flutter 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); + }); + + // Returns a MockProcess to provide for "xcrun xcodebuild -list" for a + // project that contains [targets]. + MockProcess _getMockXcodebuildListProcess(List targets) { + final Map projects = { + 'project': { + 'targets': targets, + } + }; + return MockProcess(stdout: jsonEncode(projects)); + } + + // Returns the ProcessCall to expect for checking the targets present in + // the [package]'s [platform]/Runner.xcodeproj. + ProcessCall _getTargetCheckCall(Directory package, String platform) { + return ProcessCall( + 'xcrun', + [ + 'xcodebuild', + '-list', + '-json', + '-project', + package + .childDirectory(platform) + .childDirectory('Runner.xcodeproj') + .path, + ], + null); + } + + // Returns the ProcessCall to expect for running the tests in the + // workspace [platform]/Runner.xcworkspace, with the given extra flags. + ProcessCall _getRunTestCall( + Directory package, + String platform, { + String? destination, + List extraFlags = const [], + }) { + return ProcessCall( + 'xcrun', + [ + 'xcodebuild', + 'test', + '-workspace', + '$platform/Runner.xcworkspace', + '-scheme', + 'Runner', + '-configuration', + 'Debug', + if (destination != null) ...['-destination', destination], + ...extraFlags, + 'GCC_TREAT_WARNINGS_AS_ERRORS=YES', + ], + package.path); + } + + test('fails if no platforms are provided', () async { + Error? commandError; + final List output = await runCapturingPrint( + 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'] = [ + _getMockXcodebuildListProcess(['RunnerTests', 'RunnerUITests']), + // Exit code 66 from testing indicates no tests. + MockProcess(exitCode: 66), + ]; + final List output = await runCapturingPrint( + runner, ['native-test', '--macos', '--no-unit']); + + expect( + output, + containsAllInOrder([ + contains('No tests found.'), + contains('Skipped 1 package(s)'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos', + extraFlags: ['-only-testing:RunnerUITests']), + ])); + }); + + 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'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + 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([ + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'foo_destination'), + ])); + }); + + 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 + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + await runCapturingPrint(runner, ['native-test', '--ios']); + + expect( + processRunner.recordedCalls, + orderedEquals([ + const ProcessCall( + 'xcrun', + [ + 'simctl', + 'list', + 'devices', + 'runtimes', + 'available', + '--json', + ], + null), + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'id=1E76A0FD-38AC-4537-A989-EA639D7D012A'), + ])); + }); + }); + + 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'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + ]); + + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); + + expect( + processRunner.recordedCalls, + orderedEquals([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos'), + ])); + }); + }); + + 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', '--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( + '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', '--no-unit']); + + // 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'], + errorHandler: (Error e) { + // Having no unit tests is fatal, but that's not the point of this + // test so just ignore the failure. + }); + + expect( + output, + 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 unit 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('fails when an integration test fails', () async { + final Directory pluginDir = createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/test/example_test.java', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + final String gradlewPath = pluginDir + .childDirectory('example') + .childDirectory('android') + .childFile('gradlew') + .path; + processRunner.mockProcessesForExecutable[gradlewPath] = [ + MockProcess(), // unit passes + MockProcess(exitCode: 1), // integration fails + ]; + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['native-test', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('plugin/example integration tests failed.'), + contains('The following packages had errors:'), + contains('plugin') + ]), + ); + }); + + test('fails if there are no unit tests', () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) + }, + extraFiles: [ + 'example/android/gradlew', + 'example/android/app/src/androidTest/IntegrationTest.java', + ], + ); + + Error? commandError; + final List output = await runCapturingPrint( + runner, ['native-test', '--android'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + + expect( + output, + containsAllInOrder([ + contains('No Android unit tests found for plugin/example'), + contains( + 'No unit tests ran. Plugins are required to have unit tests.'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' No unit tests ran (use --exclude if this is intentional).') + ]), + ); + }); + + test('skips if Android is not supported', () async { + createFakePlugin( + 'plugin', + 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 in integration-only mode', () async { + createFakePlugin( + 'plugin', + packagesDir, + platformSupport: { + kPlatformAndroid: const PlatformDetails(PlatformSupport.inline) + }, + ); + + final List output = await runCapturingPrint( + runner, ['native-test', '--android', '--no-unit']); + + expect( + output, + containsAllInOrder([ + 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'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-integration', + ]); + + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); + + // --no-integration should translate to '-only-testing:RunnerTests'. + expect( + processRunner.recordedCalls, + orderedEquals([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos', + extraFlags: ['-only-testing:RunnerTests']), + ])); + }); + + test('honors integration-only', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-unit', + ]); + + expect( + output, + contains( + contains('Successfully ran macOS xctest for plugin/example'))); + + // --no-unit should translate to '-only-testing:RunnerUITests'. + expect( + processRunner.recordedCalls, + orderedEquals([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos', + extraFlags: ['-only-testing:RunnerUITests']), + ])); + }); + + 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... + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess(['RunnerTests']), + ]; + + // ... then try to run only integration tests. + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--macos', + '--no-unit', + ]); + + expect( + output, + containsAllInOrder([ + contains( + 'No "RunnerUITests" target in plugin/example; skipping.'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + ])); + }); + + test('fails if there are no unit tests', () async { + final Directory pluginDirectory1 = createFakePlugin( + 'plugin', packagesDir, + platformSupport: { + kPlatformMacos: const PlatformDetails(PlatformSupport.inline), + }); + + final Directory pluginExampleDirectory = + pluginDirectory1.childDirectory('example'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess(['RunnerUITests']), + ]; + + Error? commandError; + final List output = + await runCapturingPrint(runner, ['native-test', '--macos'], + errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No "RunnerTests" target in plugin/example; skipping.'), + contains( + 'No unit tests ran. Plugins are required to have unit tests.'), + contains('The following packages had errors:'), + contains('plugin:\n' + ' No unit tests ran (use --exclude if this is intentional).'), + ])); + + expect( + processRunner.recordedCalls, + orderedEquals([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + ])); + }); + + 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([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + ])); + }); + }); + + 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'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), // iOS list + MockProcess(), // iOS run + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), // macOS list + MockProcess(), // macOS run + ]; + + final List output = await runCapturingPrint(runner, [ + 'native-test', + '--android', + '--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), + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'foo_destination'), + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos'), + ])); + }); + + 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'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + 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([ + _getTargetCheckCall(pluginExampleDirectory, 'macos'), + _getRunTestCall(pluginExampleDirectory, 'macos'), + ])); + }); + + 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'); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + 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([ + _getTargetCheckCall(pluginExampleDirectory, 'ios'), + _getRunTestCall(pluginExampleDirectory, 'ios', + destination: 'foo_destination'), + ])); + }); + + 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', + ], + ); + + processRunner.mockProcessesForExecutable['xcrun'] = [ + _getMockXcodebuildListProcess( + ['RunnerTests', 'RunnerUITests']), + ]; + + // 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/version_check_command_test.dart b/script/tool/test/version_check_command_test.dart new file mode 100644 index 000000000000..39132212d664 --- /dev/null +++ b/script/tool/test/version_check_command_test.dart @@ -0,0 +1,877 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io; + +import '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/version_check_command.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pub_semver/pub_semver.dart'; +import 'package:test/test.dart'; + +import 'common/plugin_command_test.mocks.dart'; +import 'mocks.dart'; +import 'util.dart'; + +void testAllowedVersion( + String masterVersion, + String headVersion, { + bool allowed = true, + NextVersionType? nextVersionType, +}) { + final Version master = Version.parse(masterVersion); + final Version head = Version.parse(headVersion); + final Map allowedVersions = + getAllowedNextVersions(master, newVersion: head); + if (allowed) { + expect(allowedVersions, contains(head)); + if (nextVersionType != null) { + expect(allowedVersions[head], equals(nextVersionType)); + } + } else { + expect(allowedVersions, isNot(contains(head))); + } +} + +class MockProcessResult extends Mock implements io.ProcessResult {} + +void main() { + const String indentation = ' '; + group('$VersionCheckCommand', () { + late FileSystem fileSystem; + late MockPlatform mockPlatform; + late Directory packagesDir; + late CommandRunner runner; + late RecordingProcessRunner processRunner; + 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(); + when(gitDir.path).thenReturn(packagesDir.parent.path); + when(gitDir.runCommand(any, throwOnError: anyNamed('throwOnError'))) + .thenAnswer((Invocation invocation) { + gitDirCommands.add(invocation.positionalArguments[0] as List); + final MockProcessResult mockProcessResult = MockProcessResult(); + if (invocation.positionalArguments[0][0] == 'show') { + final String? response = + gitShowResponses[invocation.positionalArguments[0][1]]; + if (response == null) { + throw const io.ProcessException('git', ['show']); + } + when(mockProcessResult.stdout as String?) + .thenReturn(response); + } else if (invocation.positionalArguments[0][0] == 'merge-base') { + when(mockProcessResult.stdout as String?) + .thenReturn('abc123'); + } + 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, + httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + }); + + test('allows valid version', () async { + createFakePlugin('plugin', packagesDir, version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + final List output = await runCapturingPrint( + runner, ['version-check', '--base-sha=master']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('1.0.0 -> 2.0.0'), + ]), + ); + expect(gitDirCommands.length, equals(1)); + expect( + gitDirCommands, + containsAll([ + equals(['show', 'master:packages/plugin/pubspec.yaml']), + ])); + }); + + test('denies invalid version', () async { + createFakePlugin('plugin', packagesDir, version: '0.2.0'); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 0.0.1', + }; + final Future> result = runCapturingPrint( + runner, ['version-check', '--base-sha=master']); + + await expectLater( + result, + throwsA(isA()), + ); + expect(gitDirCommands.length, equals(1)); + expect( + gitDirCommands, + containsAll([ + equals(['show', 'master:packages/plugin/pubspec.yaml']), + ])); + }); + + test('allows valid version without explicit base-sha', () async { + createFakePlugin('plugin', packagesDir, version: '2.0.0'); + gitShowResponses = { + 'abc123:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + final List output = + await runCapturingPrint(runner, ['version-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('1.0.0 -> 2.0.0'), + ]), + ); + }); + + test('allows valid version for new package.', () async { + createFakePlugin('plugin', packagesDir, version: '1.0.0'); + final List output = + await runCapturingPrint(runner, ['version-check']); + + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Unable to find previous version at git base.'), + ]), + ); + }); + + test('allows likely reverts.', () async { + createFakePlugin('plugin', packagesDir, version: '0.6.1'); + gitShowResponses = { + 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', + }; + final List output = + await runCapturingPrint(runner, ['version-check']); + + expect( + output, + containsAllInOrder([ + contains('New version is lower than previous version. ' + 'This is assumed to be a revert.'), + ]), + ); + }); + + test('denies lower version that could not be a simple revert', () async { + createFakePlugin('plugin', packagesDir, version: '0.5.1'); + gitShowResponses = { + 'abc123:packages/plugin/pubspec.yaml': 'version: 0.6.2', + }; + final Future> result = + runCapturingPrint(runner, ['version-check']); + + await expectLater( + result, + throwsA(isA()), + ); + }); + + test('denies invalid version without explicit base-sha', () async { + createFakePlugin('plugin', packagesDir, version: '0.2.0'); + gitShowResponses = { + 'abc123:packages/plugin/pubspec.yaml': 'version: 0.0.1', + }; + final Future> result = + runCapturingPrint(runner, ['version-check']); + + await expectLater( + result, + throwsA(isA()), + ); + }); + + test('allows minor changes to platform interfaces', () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '1.1.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + final List output = await runCapturingPrint( + runner, ['version-check', '--base-sha=master']); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('1.0.0 -> 1.1.0'), + ]), + ); + expect(gitDirCommands.length, equals(1)); + expect( + gitDirCommands, + containsAll([ + equals([ + 'show', + 'master:packages/plugin_platform_interface/pubspec.yaml' + ]), + ])); + }); + + test('disallows breaking changes to platform interfaces by default', + () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + final Future> output = runCapturingPrint( + runner, ['version-check', '--base-sha=master']); + await expectLater( + output, + throwsA(isA()), + ); + expect(gitDirCommands.length, equals(1)); + expect( + gitDirCommands, + containsAll([ + equals([ + 'show', + 'master:packages/plugin_platform_interface/pubspec.yaml' + ]), + ])); + }); + + test('allows breaking changes to platform interfaces with explanation', + () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + final File changeDescriptionFile = + fileSystem.file('change_description.txt'); + changeDescriptionFile.writeAsStringSync(''' +Some general PR description + +## Breaking change justification + +This is necessary because of X, Y, and Z + +## Another section'''); + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--change-description-file=${changeDescriptionFile.path}' + ]); + + expect( + output, + containsAllInOrder([ + contains('Allowing breaking change to plugin_platform_interface ' + 'due to "## Breaking change justification" in the change ' + 'description.'), + contains('Ran for 1 package(s) (1 with warnings)'), + ]), + ); + }); + + test('throws if a nonexistent change description file is specified', + () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--change-description-file=a_missing_file.txt' + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('No such file: a_missing_file.txt'), + ]), + ); + }); + + test('allows breaking changes to platform interfaces with bypass flag', + () async { + createFakePlugin('plugin_platform_interface', packagesDir, + version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin_platform_interface/pubspec.yaml': + 'version: 1.0.0', + }; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--ignore-platform-interface-breaks' + ]); + + expect( + output, + containsAllInOrder([ + contains('Allowing breaking change to plugin_platform_interface due ' + 'to --ignore-platform-interface-breaks'), + contains('Ran for 1 package(s) (1 with warnings)'), + ]), + ); + }); + + test('Allow empty lines in front of the first version in CHANGELOG', + () async { + const String version = '1.0.1'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); + const String changelog = ''' + +## $version +* Some changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + final List output = await runCapturingPrint( + runner, ['version-check', '--base-sha=master']); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + ]), + ); + }); + + test('Throws if versions in changelog and pubspec do not match', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.1'); + const String changelog = ''' +## 1.0.2 +* Some changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + 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('Versions in CHANGELOG.md and pubspec.yaml do not match.'), + ]), + ); + }); + + test('Success if CHANGELOG and pubspec versions match', () async { + const String version = '1.0.1'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); + + const String changelog = ''' +## $version +* Some changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + final List output = await runCapturingPrint( + runner, ['version-check', '--base-sha=master']); + expect( + output, + containsAllInOrder([ + contains('Running for plugin'), + ]), + ); + }); + + test( + 'Fail if pubspec version only matches an older version listed in CHANGELOG', + () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.0'); + + const String changelog = ''' +## 1.0.1 +* Some changes. +## 1.0.0 +* Some other changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + 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('Versions in CHANGELOG.md and pubspec.yaml do not match.'), + ]), + ); + }); + + test('Allow NEXT as a placeholder for gathering CHANGELOG entries', + () async { + const String version = '1.0.0'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); + + const String changelog = ''' +## NEXT +* Some changes that won't be published until the next time there's a release. +## $version +* 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( + output, + containsAllInOrder([ + contains('Running for plugin'), + contains('Found NEXT; validating next version in the CHANGELOG.'), + ]), + ); + }); + + test('Fail if NEXT appears after a version', () async { + const String version = '1.0.1'; + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: version); + + const String changelog = ''' +## $version +* Some changes. +## NEXT +* Some changes that should have been folded in 1.0.1. +## 1.0.0 +* Some other changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + bool hasError = false; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( + output, + containsAllInOrder([ + contains('When bumping the version for release, the NEXT section ' + 'should be incorporated into the new version\'s release notes.') + ]), + ); + }); + + test('Fail if 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'); + + const String changelog = ''' +## NEXT +* Some changes that should be listed as part of 1.0.1. +## 1.0.0 +* Some other changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + + bool hasError = false; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( + output, + containsAllInOrder([ + contains('When bumping the version for release, the NEXT section ' + 'should be incorporated into the new version\'s release notes.') + ]), + ); + }); + + test( + 'fails gracefully if the version headers are not found due to using the wrong style', + () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.0'); + + const String changelog = ''' +## NEXT +* Some changes for a later release. +# 1.0.0 +* Some other changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('Unable to find a version in CHANGELOG.md'), + contains('The current version should be on a line starting with ' + '"## ", either on the first non-empty line or after a "## NEXT" ' + 'section.'), + ]), + ); + }); + + test('fails gracefully if the version is unparseable', () async { + final Directory pluginDirectory = + createFakePlugin('plugin', packagesDir, version: '1.0.0'); + + const String changelog = ''' +## Alpha +* Some changes. +'''; + createFakeCHANGELOG(pluginDirectory, changelog); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + + Error? commandError; + final List output = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + ], errorHandler: (Error e) { + commandError = e; + }); + + expect(commandError, isA()); + expect( + output, + containsAllInOrder([ + contains('"Alpha" could not be parsed as a version.'), + ]), + ); + }); + + test('allows valid against pub', () async { + mockHttpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + '1.0.0', + ], + }; + + createFakePlugin('plugin', packagesDir, version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + final List output = await runCapturingPrint(runner, + ['version-check', '--base-sha=master', '--against-pub']); + + expect( + output, + containsAllInOrder([ + contains('plugin: Current largest version on pub: 1.0.0'), + ]), + ); + }); + + test('denies invalid against pub', () async { + mockHttpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + ], + }; + + createFakePlugin('plugin', packagesDir, version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + + bool hasError = false; + final List result = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( + result, + containsAllInOrder([ + contains(''' +${indentation}Incorrectly updated version. +${indentation}HEAD: 2.0.0, pub: 0.0.2. +${indentation}Allowed versions: {1.0.0: NextVersionType.BREAKING_MAJOR, 0.1.0: NextVersionType.MINOR, 0.0.3: NextVersionType.PATCH}''') + ]), + ); + }); + + test( + 'throw and print error message if http request failed when checking against pub', + () async { + mockHttpStatus = 400; + + createFakePlugin('plugin', packagesDir, version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + bool hasError = false; + final List result = await runCapturingPrint(runner, [ + 'version-check', + '--base-sha=master', + '--against-pub' + ], errorHandler: (Error e) { + expect(e, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + expect( + result, + containsAllInOrder([ + contains(''' +${indentation}Error fetching version on pub for plugin. +${indentation}HTTP Status 400 +${indentation}HTTP response: null +''') + ]), + ); + }); + + test('when checking against pub, allow any version if http status is 404.', + () async { + mockHttpStatus = 404; + + createFakePlugin('plugin', packagesDir, version: '2.0.0'); + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + }; + final List result = await runCapturingPrint(runner, + ['version-check', '--base-sha=master', '--against-pub']); + + expect( + result, + containsAllInOrder([ + contains('Unable to find previous version on pub server.'), + ]), + ); + }); + }); + + group('Pre 1.0', () { + test('nextVersion allows patch version', () { + testAllowedVersion('0.12.0', '0.12.0+1', + nextVersionType: NextVersionType.PATCH); + testAllowedVersion('0.12.0+4', '0.12.0+5', + nextVersionType: NextVersionType.PATCH); + }); + + test('nextVersion does not allow jumping patch', () { + testAllowedVersion('0.12.0', '0.12.0+2', allowed: false); + testAllowedVersion('0.12.0+2', '0.12.0+4', allowed: false); + }); + + test('nextVersion does not allow going back', () { + testAllowedVersion('0.12.0', '0.11.0', allowed: false); + testAllowedVersion('0.12.0+2', '0.12.0+1', allowed: false); + testAllowedVersion('0.12.0+1', '0.12.0', allowed: false); + }); + + test('nextVersion allows minor version', () { + testAllowedVersion('0.12.0', '0.12.1', + nextVersionType: NextVersionType.MINOR); + testAllowedVersion('0.12.0+4', '0.12.1', + nextVersionType: NextVersionType.MINOR); + }); + + test('nextVersion does not allow jumping minor', () { + testAllowedVersion('0.12.0', '0.12.2', allowed: false); + testAllowedVersion('0.12.0+2', '0.12.3', allowed: false); + }); + }); + + group('Releasing 1.0', () { + test('nextVersion allows releasing 1.0', () { + testAllowedVersion('0.12.0', '1.0.0', + nextVersionType: NextVersionType.BREAKING_MAJOR); + testAllowedVersion('0.12.0+4', '1.0.0', + nextVersionType: NextVersionType.BREAKING_MAJOR); + }); + + test('nextVersion does not allow jumping major', () { + testAllowedVersion('0.12.0', '2.0.0', allowed: false); + testAllowedVersion('0.12.0+4', '2.0.0', allowed: false); + }); + + test('nextVersion does not allow un-releasing', () { + testAllowedVersion('1.0.0', '0.12.0+4', allowed: false); + testAllowedVersion('1.0.0', '0.12.0', allowed: false); + }); + }); + + group('Post 1.0', () { + test('nextVersion allows patch jumps', () { + testAllowedVersion('1.0.1', '1.0.2', + nextVersionType: NextVersionType.PATCH); + testAllowedVersion('1.0.0', '1.0.1', + nextVersionType: NextVersionType.PATCH); + }); + + test('nextVersion does not allow build jumps', () { + testAllowedVersion('1.0.1', '1.0.1+1', allowed: false); + testAllowedVersion('1.0.0+5', '1.0.0+6', allowed: false); + }); + + test('nextVersion does not allow skipping patches', () { + testAllowedVersion('1.0.1', '1.0.3', allowed: false); + testAllowedVersion('1.0.0', '1.0.6', allowed: false); + }); + + test('nextVersion allows minor version jumps', () { + testAllowedVersion('1.0.1', '1.1.0', + nextVersionType: NextVersionType.MINOR); + testAllowedVersion('1.0.0', '1.1.0', + nextVersionType: NextVersionType.MINOR); + }); + + test('nextVersion does not allow skipping minor versions', () { + testAllowedVersion('1.0.1', '1.2.0', allowed: false); + testAllowedVersion('1.1.0', '1.3.0', allowed: false); + }); + + test('nextVersion allows breaking changes', () { + testAllowedVersion('1.0.1', '2.0.0', + nextVersionType: NextVersionType.BREAKING_MAJOR); + testAllowedVersion('1.0.0', '2.0.0', + nextVersionType: NextVersionType.BREAKING_MAJOR); + }); + + test('nextVersion does not allow skipping major versions', () { + testAllowedVersion('1.0.1', '3.0.0', allowed: false); + testAllowedVersion('1.1.0', '2.3.0', allowed: false); + }); + }); +} 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([])); + }); + }); + }); +}