diff --git a/.cirrus.yml b/.cirrus.yml index d269f901a180..e3d6f7881382 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -48,10 +48,10 @@ task: - CIRRUS_BUILD_ID=null pub run test - name: publishable script: - - ./script/incremental_build.sh version-check - - ./script/check_publish.sh + - ./script/tool_runner.sh version-check + - ./script/tool_runner.sh publish-check - name: format - format_script: ./script/incremental_build.sh format --fail-on-change + format_script: ./script/tool_runner.sh format --fail-on-change license_script: - dart script/tool/lib/src/main.dart license-check - name: test @@ -60,7 +60,7 @@ task: CHANNEL: "master" CHANNEL: "stable" test_script: - - ./script/incremental_build.sh test + - ./script/tool_runner.sh test - name: analyze_master env: matrix: @@ -68,8 +68,8 @@ task: tool_script: - cd script/tool - dart analyze --fatal-infos - plugins_script: - - ./script/incremental_build.sh analyze + script: + - ./script/tool_runner.sh analyze ## TODO(cyanglaz): ## Combing stable and master analyze jobs when integration test null safety is ready on flutter stable. - name: analyze_stable @@ -78,7 +78,7 @@ task: CHANNEL: "stable" script: - find . -depth -type d -wholename '*_web/example' -exec rm -rf {} \; - - ./script/incremental_build.sh analyze + - ./script/tool_runner.sh analyze ### Android tasks ### - name: build_all_plugins_apk env: @@ -111,9 +111,9 @@ task: CHANNEL: "stable" build_script: - flutter config --enable-linux-desktop - - ./script/incremental_build.sh build-examples --linux - test_script: - - xvfb-run ./script/incremental_build.sh drive-examples --linux + - ./script/tool_runner.sh build-examples --linux + drive_script: + - xvfb-run ./script/tool_runner.sh drive-examples --linux # Heavy-workload Linux tasks. # These use machines with more CPUs and memory, so will reduce parallelization @@ -154,11 +154,11 @@ task: - echo "$CIRRUS_COMMIT_MESSAGE" > /tmp/cirrus_commit_message.txt - export CIRRUS_CHANGE_MESSAGE="" - export CIRRUS_COMMIT_MESSAGE="" - - ./script/incremental_build.sh build-examples --apk - - ./script/incremental_build.sh java-test # must come after apk build + - ./script/tool_runner.sh build-examples --apk + - ./script/tool_runner.sh java-test # must come after apk build - if [[ -n "$GCLOUD_FIREBASE_TESTLAB_KEY" ]]; then - echo $GCLOUD_FIREBASE_TESTLAB_KEY > ${HOME}/gcloud-service-key.json - - ./script/incremental_build.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 + - ./script/tool_runner.sh firebase-test-lab --device model=flame,version=29 --device model=starqlteue,version=26 - else - echo "This user does not have permission to run Firebase Test Lab tests." - fi @@ -167,6 +167,9 @@ task: ### Web tasks ### - name: build-web+drive-examples env: + # Currently missing; see https://github.com/flutter/flutter/issues/81982 + # and https://github.com/flutter/flutter/issues/82211 + PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "file_selector,image_picker_for_web,shared_preferences_web,video_player_web" matrix: CHANNEL: "master" CHANNEL: "stable" @@ -177,11 +180,11 @@ task: - dart lib/web_driver_installer.dart chromedriver --install-only - ./chromedriver/chromedriver --port=4444 & build_script: - - ./script/incremental_build.sh build-examples --web - test_script: + - ./script/tool_runner.sh build-examples --web + drive_script: # TODO(stuartmorgan): Eliminate this check once 2.1 reaches stable. - if [[ "$CHANNEL" == "master" ]]; then - - ./script/incremental_build.sh drive-examples --web + - ./script/tool_runner.sh drive-examples --web --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS - else - echo "Requires null-safe integration_test; skipping." - fi @@ -203,6 +206,10 @@ task: env: PATH: $PATH:/usr/local/bin PLUGINS_TO_SKIP_XCTESTS: "integration_test" + # in_app_purchase_ios is currently missing tests; see https://github.com/flutter/flutter/issues/81695 + # ios_platform_images is currently missing tests; see https://github.com/flutter/flutter/issues/82208 + # sensor hangs on CI. + PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS: "in_app_purchase_ios,ios_platform_images,sensors" matrix: PLUGIN_SHARDING: "--shardIndex 0 --shardCount 4" PLUGIN_SHARDING: "--shardIndex 1 --shardCount 4" @@ -216,13 +223,14 @@ task: - xcrun simctl list - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-14-3 | xargs xcrun simctl boot build_script: - - ./script/incremental_build.sh build-examples --ipa - test_script: - - ./script/incremental_build.sh xctest --skip $PLUGINS_TO_SKIP_XCTESTS --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" + - ./script/tool_runner.sh build-examples --ipa + xctest_script: + - ./script/tool_runner.sh xctest --skip $PLUGINS_TO_SKIP_XCTESTS --ios-destination "platform=iOS Simulator,name=iPhone 11,OS=latest" + drive_script: # `drive-examples` contains integration tests, which changes the UI of the application. # This UI change sometimes affects `xctest`. # So we run `drive-examples` after `xctest`, changing the order will result ci failure. - - ./script/incremental_build.sh drive-examples --ios + - ./script/tool_runner.sh drive-examples --ios --exclude $PLUGINS_TO_EXCLUDE_INTEGRATION_TESTS ### macOS desktop tasks ### - name: build_all_plugins_macos env: @@ -240,9 +248,9 @@ task: PATH: $PATH:/usr/local/bin build_script: - flutter config --enable-macos-desktop - - ./script/incremental_build.sh build-examples --macos --no-ipa - test_script: - - ./script/incremental_build.sh drive-examples --macos + - ./script/tool_runner.sh build-examples --macos --no-ipa + drive_script: + - ./script/tool_runner.sh drive-examples --macos task: # Don't use FLUTTER_UPGRADE_TEMPLATE, Flutter tooling not needed. @@ -253,4 +261,4 @@ task: script: # TODO(jmagman): Lint macOS podspecs but skip any that fail library validation. - find . -name "*.podspec" | xargs grep -l "osx" | xargs rm - - ./script/incremental_build.sh podspecs + - ./script/tool_runner.sh podspecs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d11c6ad59dc..ed3dd7932d0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ To run the integration tests using Flutter driver: ```console cd example -flutter drive test_driver/.dart +flutter drive --driver test_driver/integration_test.dart --target integration_test/.dart ``` To run integration tests as instrumentation tests on a local Android device: diff --git a/packages/android_alarm_manager/example/test_driver/integration_test.dart b/packages/android_alarm_manager/example/test_driver/integration_test.dart index c08f4a817f6c..6a0e6fa82dbe 100644 --- a/packages/android_alarm_manager/example/test_driver/integration_test.dart +++ b/packages/android_alarm_manager/example/test_driver/integration_test.dart @@ -4,19 +4,6 @@ // @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; +import 'package:integration_test/integration_test_driver.dart'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = await driver.requestData( - null, - timeout: const Duration(minutes: 1), - ); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/android_intent/example/test_driver/integration_test.dart b/packages/android_intent/example/test_driver/integration_test.dart index cc91e52ef2ef..6a0e6fa82dbe 100644 --- a/packages/android_intent/example/test_driver/integration_test.dart +++ b/packages/android_intent/example/test_driver/integration_test.dart @@ -2,21 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; +import 'package:integration_test/integration_test_driver.dart'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = await driver.requestData( - null, - timeout: const Duration(minutes: 1), - ); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/battery/battery/integration_test/battery_test.dart b/packages/battery/battery/example/integration_test/battery_test.dart similarity index 51% rename from packages/battery/battery/integration_test/battery_test.dart rename to packages/battery/battery/example/integration_test/battery_test.dart index 24f5a5adc7f9..eced27e5a1cd 100644 --- a/packages/battery/battery/integration_test/battery_test.dart +++ b/packages/battery/battery/example/integration_test/battery_test.dart @@ -2,8 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.9 +// @dart=2.9 +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:battery/battery.dart'; import 'package:integration_test/integration_test.dart'; @@ -13,7 +14,18 @@ void main() { testWidgets('Can get battery level', (WidgetTester tester) async { final Battery battery = Battery(); - final int batteryLevel = await battery.batteryLevel; + int batteryLevel; + try { + batteryLevel = await battery.batteryLevel; + } on PlatformException catch (e) { + // The "UNAVAIBLE" error just means that the system reported the battery + // level as unknown (e.g., the test is running on simulator); it still + // indicates that the plugin itself is working as expected, so consider it + // as passing. + if (e.code == 'UNAVAILABLE') { + batteryLevel = 1; + } + } expect(batteryLevel, isNotNull); }); } diff --git a/packages/battery/battery/example/test_driver/integration_test.dart b/packages/battery/battery/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/battery/battery/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/connectivity/connectivity/example/test_driver/integration_test/connectivity_test.dart b/packages/connectivity/connectivity/example/integration_test/connectivity_test.dart similarity index 100% rename from packages/connectivity/connectivity/example/test_driver/integration_test/connectivity_test.dart rename to packages/connectivity/connectivity/example/integration_test/connectivity_test.dart diff --git a/packages/connectivity/connectivity/example/macos/Podfile b/packages/connectivity/connectivity/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/connectivity/connectivity/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/connectivity/connectivity/example/test_driver/integration_test.dart b/packages/connectivity/connectivity/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/connectivity/connectivity/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/connectivity/connectivity/example/test_driver/test/integration_test.dart b/packages/connectivity/connectivity/example/test_driver/test/integration_test.dart deleted file mode 100644 index 79c1875f85d2..000000000000 --- a/packages/connectivity/connectivity/example/test_driver/test/integration_test.dart +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// TODO(amirh): Remove this once flutter_driver supports null safety. -// https://github.com/flutter/flutter/issues/71379 -// @dart = 2.9 - -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/packages/connectivity/connectivity/integration_test/connectivity_test.dart b/packages/connectivity/connectivity/integration_test/connectivity_test.dart deleted file mode 100644 index 454ddd4c351b..000000000000 --- a/packages/connectivity/connectivity/integration_test/connectivity_test.dart +++ /dev/null @@ -1,27 +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. - -// TODO(cyanglaz): Remove once https://github.com/flutter/plugins/pull/3158 is landed. -// @dart = 2.9 - -import 'package:integration_test/integration_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:connectivity/connectivity.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('Connectivity test driver', () { - Connectivity _connectivity; - - setUpAll(() async { - _connectivity = Connectivity(); - }); - - testWidgets('test connectivity result', (WidgetTester tester) async { - final ConnectivityResult result = await _connectivity.checkConnectivity(); - expect(result, isNotNull); - }); - }); -} diff --git a/packages/connectivity/connectivity_for_web/example/test_driver/integration_test.dart b/packages/connectivity/connectivity_for_web/example/test_driver/integration_test.dart index f26b6a310cfe..4f10f2a522f3 100644 --- a/packages/connectivity/connectivity_for_web/example/test_driver/integration_test.dart +++ b/packages/connectivity/connectivity_for_web/example/test_driver/integration_test.dart @@ -4,4 +4,4 @@ import 'package:integration_test/integration_test_driver.dart'; -Future main() async => integrationDriver(); +Future main() => integrationDriver(); diff --git a/packages/connectivity/connectivity_macos/example/test_driver/integration_test/connectivity_test.dart b/packages/connectivity/connectivity_macos/example/integration_test/connectivity_test.dart similarity index 100% rename from packages/connectivity/connectivity_macos/example/test_driver/integration_test/connectivity_test.dart rename to packages/connectivity/connectivity_macos/example/integration_test/connectivity_test.dart diff --git a/packages/connectivity/connectivity_macos/example/macos/Podfile b/packages/connectivity/connectivity_macos/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/connectivity/connectivity_macos/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/connectivity/connectivity_macos/example/test_driver/integration_test.dart b/packages/connectivity/connectivity_macos/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/connectivity/connectivity_macos/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/connectivity/connectivity_macos/example/test_driver/test/integration_test.dart b/packages/connectivity/connectivity_macos/example/test_driver/test/integration_test.dart deleted file mode 100644 index 9647a12d77ce..000000000000 --- a/packages/connectivity/connectivity_macos/example/test_driver/test/integration_test.dart +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// @dart = 2.9 -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/packages/device_info/device_info/example/test_driver/integration_test.dart b/packages/device_info/device_info/example/test_driver/integration_test.dart index 156ecae508c1..6a0e6fa82dbe 100644 --- a/packages/device_info/device_info/example/test_driver/integration_test.dart +++ b/packages/device_info/device_info/example/test_driver/integration_test.dart @@ -2,19 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// TODO(cyanglaz): Remove once https://github.com/flutter/flutter/issues/59879 is fixed. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md index f36c9b0a229c..eefc0453181e 100644 --- a/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.0.4 + +* Unpin iOS GoogleMaps pod dependency version. + ## 2.0.3 * Fix incorrect typecast in TileOverlay example. diff --git a/packages/google_maps_flutter/google_maps_flutter/example/test_driver/integration_test.dart b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/integration_test.dart index ab30698581d0..6a0e6fa82dbe 100644 --- a/packages/google_maps_flutter/google_maps_flutter/example/test_driver/integration_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter/example/test_driver/integration_test.dart @@ -1,18 +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. + // @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); 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 021abfee71ab..9a1f04d59759 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 @@ -17,8 +17,7 @@ Downloaded by pub (not CocoaPods). s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' s.dependency 'Flutter' - # TODO: Unpin this once the fix for b/163474612 or b/163359804 rolls (avoid v3.10!) - s.dependency 'GoogleMaps', '< 3.10' + s.dependency 'GoogleMaps' s.static_framework = true s.platform = :ios, '8.0' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64' } diff --git a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml index 8c6de691ecf3..cdd629c39170 100644 --- a/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter/pubspec.yaml @@ -1,7 +1,7 @@ name: google_maps_flutter description: A Flutter plugin for integrating Google Maps in iOS and Android applications. homepage: https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter -version: 2.0.3 +version: 2.0.4 dependencies: flutter: diff --git a/packages/google_maps_flutter/google_maps_flutter_web/example/test_driver/integration_test.dart b/packages/google_maps_flutter/google_maps_flutter_web/example/test_driver/integration_test.dart index f26b6a310cfe..4f10f2a522f3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_web/example/test_driver/integration_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_web/example/test_driver/integration_test.dart @@ -4,4 +4,4 @@ import 'package:integration_test/integration_test_driver.dart'; -Future main() async => integrationDriver(); +Future main() => integrationDriver(); diff --git a/packages/google_sign_in/google_sign_in/CHANGELOG.md b/packages/google_sign_in/google_sign_in/CHANGELOG.md index 4e8ed80cba9c..aa62f302b9c2 100644 --- a/packages/google_sign_in/google_sign_in/CHANGELOG.md +++ b/packages/google_sign_in/google_sign_in/CHANGELOG.md @@ -1,3 +1,8 @@ +## 5.0.3 + +* Fixed links in `README.md`. +* Added documentation for usage on the web. + ## 5.0.2 * Fix flutter/flutter#48602 iOS flow shows account selection, if user is signed in to Google on the device. diff --git a/packages/google_sign_in/google_sign_in/README.md b/packages/google_sign_in/google_sign_in/README.md index 61c4380cdcb7..6ed21c0fedd2 100644 --- a/packages/google_sign_in/google_sign_in/README.md +++ b/packages/google_sign_in/google_sign_in/README.md @@ -1,34 +1,44 @@ -# google_sign_in - [![pub package](https://img.shields.io/pub/v/google_sign_in.svg)](https://pub.dev/packages/google_sign_in) A Flutter plugin for [Google Sign In](https://developers.google.com/identity/). -*Note*: This plugin is still under development, and some APIs might not be available yet. [Feedback](https://github.com/flutter/flutter/issues) and [Pull Requests](https://github.com/flutter/plugins/pulls) are most welcome! +_Note_: This plugin is still under development, and some APIs might not be +available yet. [Feedback](https://github.com/flutter/flutter/issues) and +[Pull Requests](https://github.com/flutter/plugins/pulls) are most welcome! + +## Platform integration -## Android integration +### Android integration -To access Google Sign-In, you'll need to make sure to [register your -application](https://developers.google.com/mobile/add?platform=android). +To access Google Sign-In, you'll need to make sure to +[register your application](https://firebase.google.com/docs/android/setup). You don't need to include the google-services.json file in your app unless you are using Google services that require it. You do need to enable the OAuth APIs -that you want, using the [Google Cloud Platform API -manager](https://console.developers.google.com/). For example, if you -want to mimic the behavior of the Google Sign-In sample app, you'll need to -enable the [Google People API](https://developers.google.com/people/). - -Make sure you've filled out all required fields in the console for [OAuth consent screen](https://console.developers.google.com/apis/credentials/consent). Otherwise, you may encounter `APIException` errors. - -## iOS integration - -1. [First register your application](https://developers.google.com/mobile/add?platform=ios). -2. Make sure the file you download in step 1 is named `GoogleService-Info.plist`. -3. Move or copy `GoogleService-Info.plist` into the `[my_project]/ios/Runner` directory. -4. Open Xcode, then right-click on `Runner` directory and select `Add Files to "Runner"`. +that you want, using the +[Google Cloud Platform API manager](https://console.developers.google.com/). For +example, if you want to mimic the behavior of the Google Sign-In sample app, +you'll need to enable the +[Google People API](https://developers.google.com/people/). + +Make sure you've filled out all required fields in the console for +[OAuth consent screen](https://console.developers.google.com/apis/credentials/consent). +Otherwise, you may encounter `APIException` errors. + +### iOS integration + +1. [First register your application](https://firebase.google.com/docs/ios/setup). +2. Make sure the file you download in step 1 is named + `GoogleService-Info.plist`. +3. Move or copy `GoogleService-Info.plist` into the `[my_project]/ios/Runner` + directory. +4. Open Xcode, then right-click on `Runner` directory and select + `Add Files to "Runner"`. 5. Select `GoogleService-Info.plist` from the file manager. -6. A dialog will show up and ask you to select the targets, select the `Runner` target. -7. Then add the `CFBundleURLTypes` attributes below into the `[my_project]/ios/Runner/Info.plist` file. +6. A dialog will show up and ask you to select the targets, select the `Runner` + target. +7. Then add the `CFBundleURLTypes` attributes below into the + `[my_project]/ios/Runner/Info.plist` file. ```xml @@ -49,23 +59,33 @@ Make sure you've filled out all required fields in the console for [OAuth consen ``` -### iOS additional requirement +#### iOS additional requirement -Note that according to https://developer.apple.com/sign-in-with-apple/get-started, -starting June 30, 2020, apps that use login services must also offer a "Sign in -with Apple" option when submitting to the Apple App Store. +Note that according to +https://developer.apple.com/sign-in-with-apple/get-started, starting June 30, +2020, apps that use login services must also offer a "Sign in with Apple" option +when submitting to the Apple App Store. Consider also using an Apple sign in plugin from pub.dev. -The Flutter Favorite [sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) -plugin could be an option. +The Flutter Favorite +[sign_in_with_apple](https://pub.dev/packages/sign_in_with_apple) plugin could +be an option. + +### Web integration + +For web integration details, see the +[`google_sign_in_web` package](https://pub.dev/packages/google_sign_in_web). ## Usage ### Import the package -To use this plugin, follow the [plugin installation instructions](https://pub.dev/packages/google_sign_in#pub-pkg-tab-installing). + +To use this plugin, follow the +[plugin installation instructions](https://pub.dev/packages/google_sign_in/install). ### Use the plugin + Add the following import to your Dart code: ```dart @@ -82,6 +102,7 @@ GoogleSignIn _googleSignIn = GoogleSignIn( ], ); ``` + [Full list of available scopes](https://developers.google.com/identity/protocols/googlescopes). You can now use the `GoogleSignIn` class to authenticate in your Dart code, e.g. @@ -98,13 +119,5 @@ Future _handleSignIn() async { ## Example -Find the example wiring in the [Google sign-in example application](https://github.com/flutter/plugins/blob/master/packages/google_sign_in/google_sign_in/example/lib/main.dart). - -## API details - -See the [google_sign_in.dart](https://github.com/flutter/plugins/blob/master/packages/google_sign_in/google_sign_in/lib/google_sign_in.dart) for more API details. - -## Issues and feedback - -Please file [issues](https://github.com/flutter/flutter/issues/new) -to send feedback or report a bug. Thank you! +Find the example wiring in the +[Google sign-in example application](https://github.com/flutter/plugins/blob/master/packages/google_sign_in/google_sign_in/example/lib/main.dart). diff --git a/packages/google_sign_in/google_sign_in/integration_test/google_sign_in_test.dart b/packages/google_sign_in/google_sign_in/example/integration_test/google_sign_in_test.dart similarity index 100% rename from packages/google_sign_in/google_sign_in/integration_test/google_sign_in_test.dart rename to packages/google_sign_in/google_sign_in/example/integration_test/google_sign_in_test.dart diff --git a/packages/google_sign_in/google_sign_in/example/test_driver/integration_test.dart b/packages/google_sign_in/google_sign_in/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/google_sign_in/google_sign_in/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/google_sign_in/google_sign_in/pubspec.yaml b/packages/google_sign_in/google_sign_in/pubspec.yaml index 388814bb1409..9c0159c15f0b 100644 --- a/packages/google_sign_in/google_sign_in/pubspec.yaml +++ b/packages/google_sign_in/google_sign_in/pubspec.yaml @@ -2,7 +2,7 @@ name: google_sign_in description: Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS. homepage: https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in -version: 5.0.2 +version: 5.0.3 flutter: plugin: diff --git a/packages/google_sign_in/google_sign_in/test_driver/integration_test.dart b/packages/google_sign_in/google_sign_in/test_driver/integration_test.dart deleted file mode 100644 index 257b0d3c0930..000000000000 --- a/packages/google_sign_in/google_sign_in/test_driver/integration_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// @dart = 2.9 - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/packages/google_sign_in/google_sign_in_web/example/test_driver/integration_test.dart b/packages/google_sign_in/google_sign_in_web/example/test_driver/integration_test.dart index f26b6a310cfe..4f10f2a522f3 100644 --- a/packages/google_sign_in/google_sign_in_web/example/test_driver/integration_test.dart +++ b/packages/google_sign_in/google_sign_in_web/example/test_driver/integration_test.dart @@ -4,4 +4,4 @@ import 'package:integration_test/integration_test_driver.dart'; -Future main() async => integrationDriver(); +Future main() => integrationDriver(); diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 58002a8630b4..9c2bcc72b7e3 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,9 +1,8 @@ ## 0.7.5 * Fixes an issue where image rotation is wrong when Select Photos chose and image is scaled. -* Breaking Changes: - * Migrate to PHPicker for iOS 14 and higher versions to pick image from the photo library. - * Implement the limited permission to pick photo from the photo library when Select Photo is chose. +* Migrate to PHPicker for iOS 14 and higher versions to pick image from the photo library. +* Implement the limited permission to pick photo from the photo library when Select Photo is chose. ## 0.7.4 diff --git a/packages/image_picker/image_picker/integration_test/old_image_picker_test.dart b/packages/image_picker/image_picker/example/integration_test/old_image_picker_test.dart similarity index 100% rename from packages/image_picker/image_picker/integration_test/old_image_picker_test.dart rename to packages/image_picker/image_picker/example/integration_test/old_image_picker_test.dart diff --git a/packages/image_picker/image_picker/example/test_driver/integration_test.dart b/packages/image_picker/image_picker/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/image_picker/image_picker/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/image_picker/image_picker/example/test_driver/test/integration_test.dart b/packages/image_picker/image_picker/example/test_driver/test/integration_test.dart deleted file mode 100644 index 4c4c006068b8..000000000000 --- a/packages/image_picker/image_picker/example/test_driver/test/integration_test.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. - -// @dart = 2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md index 5e7f54560b8c..45b8b7661203 100644 --- a/packages/in_app_purchase/in_app_purchase/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase/CHANGELOG.md @@ -354,7 +354,7 @@ Beta release. * Ability to list products, load previous purchases, and make purchases. * Simplified Dart API that's been unified for ease of use. -* Platform specific APIs more directly exposing `StoreKit` and `BillingClient`. +* Platform-specific APIs more directly exposing `StoreKit` and `BillingClient`. Includes: diff --git a/packages/in_app_purchase/in_app_purchase/README.md b/packages/in_app_purchase/in_app_purchase/README.md index a51187e792ab..7e433a089251 100644 --- a/packages/in_app_purchase/in_app_purchase/README.md +++ b/packages/in_app_purchase/in_app_purchase/README.md @@ -81,6 +81,8 @@ void main() { } ``` +**Note:** It is not necessary to depend on `com.android.billingclient:billing` in your own app's `android/app/build.gradle` file. If you choose to do so know that conflicts might occur. + ### Listening to purchase updates In your app's `initState` method, subscribe to any incoming purchases. These diff --git a/packages/in_app_purchase/in_app_purchase/integration_test/in_app_purchase_test.dart b/packages/in_app_purchase/in_app_purchase/example/integration_test/in_app_purchase_test.dart similarity index 100% rename from packages/in_app_purchase/in_app_purchase/integration_test/in_app_purchase_test.dart rename to packages/in_app_purchase/in_app_purchase/example/integration_test/in_app_purchase_test.dart diff --git a/packages/in_app_purchase/in_app_purchase/example/test_driver/integration_test.dart b/packages/in_app_purchase/in_app_purchase/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/in_app_purchase/in_app_purchase/example/test_driver/test/integration_test.dart b/packages/in_app_purchase/in_app_purchase/example/test_driver/test/integration_test.dart deleted file mode 100644 index 4c4c006068b8..000000000000 --- a/packages/in_app_purchase/in_app_purchase/example/test_driver/test/integration_test.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. - -// @dart = 2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/packages/in_app_purchase/in_app_purchase/lib/src/in_app_purchase/product_details.dart b/packages/in_app_purchase/in_app_purchase/lib/src/in_app_purchase/product_details.dart index ccdec42b7303..4ba61305e445 100644 --- a/packages/in_app_purchase/in_app_purchase/lib/src/in_app_purchase/product_details.dart +++ b/packages/in_app_purchase/in_app_purchase/lib/src/in_app_purchase/product_details.dart @@ -90,7 +90,7 @@ class ProductDetailsResponse { /// The list of identifiers that are in the `identifiers` of [InAppPurchaseConnection.queryProductDetails] but failed to be fetched. /// - /// There's multiple platform specific reasons that product information could fail to be fetched, + /// There's multiple platform-specific reasons that product information could fail to be fetched, /// ranging from products not being correctly configured in the storefront to the queried IDs not existing. final List notFoundIDs; diff --git a/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md new file mode 100644 index 000000000000..d46c124b9011 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +* Initial open-source release. \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_android/LICENSE b/packages/in_app_purchase/in_app_purchase_android/LICENSE new file mode 100644 index 000000000000..c6823b81eb84 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/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/in_app_purchase/in_app_purchase_android/README.md b/packages/in_app_purchase/in_app_purchase_android/README.md new file mode 100644 index 000000000000..41618fa15d7b --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/README.md @@ -0,0 +1,38 @@ +# in_app_purchase_android + +The Android implementation of [`in_app_purchase`][1]. + +## Usage + +### Import the package + +This package has been endorsed, meaning that you only need to add `in_app_purchase` +as a dependency in your `pubspec.yaml`. It will be automatically included in your app +when you depend on `package:in_app_purchase`. + +This is what the above means to your `pubspec.yaml`: + +```yaml +... +dependencies: + ... + in_app_purchase: ^0.6.0 + ... +``` + +If you wish to use the Android package only, you can add `in_app_purchase_android` as a +dependency: + +```yaml +... +dependencies: + ... + in_app_purchase_android: ^1.0.0 + ... +``` + +## TODO +- [ ] Add an example application demonstrating the use of the [in_app_purchase_android] package (see also issue [flutter/flutter#81695](https://github.com/flutter/flutter/issues/81695)). + + +[1]: ../in_app_purchase/in_app_purchase \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml b/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml new file mode 100644 index 000000000000..5aeb4e7c5e21 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../../analysis_options_legacy.yaml diff --git a/packages/in_app_purchase/in_app_purchase_android/android/build.gradle b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle new file mode 100644 index 000000000000..a36f70137129 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/build.gradle @@ -0,0 +1,48 @@ +group 'io.flutter.plugins.inapppurchase' +version '1.0-SNAPSHOT' + +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + jcenter() + } +} + +apply plugin: 'com.android.library' + +android { + compileSdkVersion 29 + + defaultConfig { + minSdkVersion 16 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + lintOptions { + disable 'InvalidPackage' + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation 'androidx.annotation:annotation:1.0.0' + implementation 'com.android.billingclient:billing:3.0.2' + testImplementation 'junit:junit:4.12' + testImplementation 'org.json:json:20180813' + testImplementation 'org.mockito:mockito-core:3.6.0' + androidTestImplementation 'androidx.test:runner:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties b/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties new file mode 100644 index 000000000000..8bd86f680510 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx1536M diff --git a/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties b/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..73eba353b126 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Oct 29 10:30:44 PDT 2018 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip diff --git a/packages/in_app_purchase/in_app_purchase_android/android/settings.gradle b/packages/in_app_purchase/in_app_purchase_android/android/settings.gradle new file mode 100644 index 000000000000..58efd2e9323e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'in_app_purchase' diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/AndroidManifest.xml b/packages/in_app_purchase/in_app_purchase_android/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..ae902de2368a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java new file mode 100644 index 000000000000..7b21cbf2e6f5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactory.java @@ -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. + +package io.flutter.plugins.inapppurchase; + +import android.content.Context; +import androidx.annotation.NonNull; +import com.android.billingclient.api.BillingClient; +import io.flutter.plugin.common.MethodChannel; + +/** Responsible for creating a {@link BillingClient} object. */ +interface BillingClientFactory { + + /** + * Creates and returns a {@link BillingClient}. + * + * @param context The context used to create the {@link BillingClient}. + * @param channel The method channel used to create the {@link BillingClient}. + * @param enablePendingPurchases Whether to enable pending purchases. Throws an exception if it is + * false. + * @return The {@link BillingClient} object that is created. + */ + BillingClient createBillingClient( + @NonNull Context context, @NonNull MethodChannel channel, boolean enablePendingPurchases); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.java new file mode 100644 index 000000000000..c256d2c59551 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/BillingClientFactoryImpl.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.inapppurchase; + +import android.content.Context; +import com.android.billingclient.api.BillingClient; +import io.flutter.plugin.common.MethodChannel; + +/** The implementation for {@link BillingClientFactory} for the plugin. */ +final class BillingClientFactoryImpl implements BillingClientFactory { + + @Override + public BillingClient createBillingClient( + Context context, MethodChannel channel, boolean enablePendingPurchases) { + BillingClient.Builder builder = BillingClient.newBuilder(context); + if (enablePendingPurchases) { + builder.enablePendingPurchases(); + } + return builder.setListener(new PluginPurchaseListener(channel)).build(); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java new file mode 100644 index 000000000000..e4719f030d53 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/InAppPurchasePlugin.java @@ -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. + +package io.flutter.plugins.inapppurchase; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import androidx.annotation.VisibleForTesting; +import com.android.billingclient.api.BillingClient; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodChannel; + +/** Wraps a {@link BillingClient} instance and responds to Dart calls for it. */ +public class InAppPurchasePlugin implements FlutterPlugin, ActivityAware { + + @VisibleForTesting + static final class MethodNames { + static final String IS_READY = "BillingClient#isReady()"; + static final String START_CONNECTION = + "BillingClient#startConnection(BillingClientStateListener)"; + static final String END_CONNECTION = "BillingClient#endConnection()"; + static final String ON_DISCONNECT = "BillingClientStateListener#onBillingServiceDisconnected()"; + static final String QUERY_SKU_DETAILS = + "BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)"; + static final String LAUNCH_BILLING_FLOW = + "BillingClient#launchBillingFlow(Activity, BillingFlowParams)"; + static final String ON_PURCHASES_UPDATED = + "PurchasesUpdatedListener#onPurchasesUpdated(int, List)"; + static final String QUERY_PURCHASES = "BillingClient#queryPurchases(String)"; + static final String QUERY_PURCHASE_HISTORY_ASYNC = + "BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)"; + static final String CONSUME_PURCHASE_ASYNC = + "BillingClient#consumeAsync(String, ConsumeResponseListener)"; + static final String ACKNOWLEDGE_PURCHASE = + "BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)"; + + private MethodNames() {}; + } + + private MethodChannel methodChannel; + private MethodCallHandlerImpl methodCallHandler; + + /** Plugin registration. */ + @SuppressWarnings("deprecation") + public static void registerWith(io.flutter.plugin.common.PluginRegistry.Registrar registrar) { + InAppPurchasePlugin plugin = new InAppPurchasePlugin(); + plugin.setupMethodChannel(registrar.activity(), registrar.messenger(), registrar.context()); + ((Application) registrar.context().getApplicationContext()) + .registerActivityLifecycleCallbacks(plugin.methodCallHandler); + } + + @Override + public void onAttachedToEngine(FlutterPlugin.FlutterPluginBinding binding) { + setupMethodChannel( + /*activity=*/ null, binding.getBinaryMessenger(), binding.getApplicationContext()); + } + + @Override + public void onDetachedFromEngine(FlutterPlugin.FlutterPluginBinding binding) { + teardownMethodChannel(); + } + + @Override + public void onAttachedToActivity(ActivityPluginBinding binding) { + methodCallHandler.setActivity(binding.getActivity()); + } + + @Override + public void onDetachedFromActivity() { + methodCallHandler.setActivity(null); + methodCallHandler.onDetachedFromActivity(); + } + + @Override + public void onReattachedToActivityForConfigChanges(ActivityPluginBinding binding) { + onAttachedToActivity(binding); + } + + @Override + public void onDetachedFromActivityForConfigChanges() { + methodCallHandler.setActivity(null); + } + + private void setupMethodChannel(Activity activity, BinaryMessenger messenger, Context context) { + methodChannel = new MethodChannel(messenger, "plugins.flutter.io/in_app_purchase"); + methodCallHandler = + new MethodCallHandlerImpl(activity, context, methodChannel, new BillingClientFactoryImpl()); + methodChannel.setMethodCallHandler(methodCallHandler); + } + + private void teardownMethodChannel() { + methodChannel.setMethodCallHandler(null); + methodChannel = null; + methodCallHandler = null; + } + + @VisibleForTesting + void setMethodCallHandler(MethodCallHandlerImpl methodCallHandler) { + this.methodCallHandler = methodCallHandler; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java new file mode 100644 index 000000000000..cfcb81ae05b5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/MethodCallHandlerImpl.java @@ -0,0 +1,382 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.inapppurchase; + +import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; +import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesResult; +import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.AcknowledgePurchaseResponseListener; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingFlowParams.ProrationMode; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.ConsumeParams; +import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.PurchaseHistoryRecord; +import com.android.billingclient.api.PurchaseHistoryResponseListener; +import com.android.billingclient.api.SkuDetails; +import com.android.billingclient.api.SkuDetailsParams; +import com.android.billingclient.api.SkuDetailsResponseListener; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Handles method channel for the plugin. */ +class MethodCallHandlerImpl + implements MethodChannel.MethodCallHandler, Application.ActivityLifecycleCallbacks { + + private static final String TAG = "InAppPurchasePlugin"; + private static final String LOAD_SKU_DOC_URL = + "https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/README.md#loading-products-for-sale"; + + @Nullable private BillingClient billingClient; + private final BillingClientFactory billingClientFactory; + + @Nullable private Activity activity; + private final Context applicationContext; + private final MethodChannel methodChannel; + + private HashMap cachedSkus = new HashMap<>(); + + /** Constructs the MethodCallHandlerImpl */ + MethodCallHandlerImpl( + @Nullable Activity activity, + @NonNull Context applicationContext, + @NonNull MethodChannel methodChannel, + @NonNull BillingClientFactory billingClientFactory) { + this.billingClientFactory = billingClientFactory; + this.applicationContext = applicationContext; + this.activity = activity; + this.methodChannel = methodChannel; + } + + /** + * Sets the activity. Should be called as soon as the the activity is available. When the activity + * becomes unavailable, call this method again with {@code null}. + */ + void setActivity(@Nullable Activity activity) { + this.activity = activity; + } + + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) {} + + @Override + public void onActivityStarted(Activity activity) {} + + @Override + public void onActivityResumed(Activity activity) {} + + @Override + public void onActivityPaused(Activity activity) {} + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) {} + + @Override + public void onActivityDestroyed(Activity activity) { + if (this.activity == activity && this.applicationContext != null) { + ((Application) this.applicationContext).unregisterActivityLifecycleCallbacks(this); + endBillingClientConnection(); + } + } + + @Override + public void onActivityStopped(Activity activity) {} + + void onDetachedFromActivity() { + endBillingClientConnection(); + } + + @Override + public void onMethodCall(MethodCall call, MethodChannel.Result result) { + switch (call.method) { + case InAppPurchasePlugin.MethodNames.IS_READY: + isReady(result); + break; + case InAppPurchasePlugin.MethodNames.START_CONNECTION: + startConnection( + (int) call.argument("handle"), + (boolean) call.argument("enablePendingPurchases"), + result); + break; + case InAppPurchasePlugin.MethodNames.END_CONNECTION: + endConnection(result); + break; + case InAppPurchasePlugin.MethodNames.QUERY_SKU_DETAILS: + List skusList = call.argument("skusList"); + querySkuDetailsAsync((String) call.argument("skuType"), skusList, result); + break; + case InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW: + launchBillingFlow( + (String) call.argument("sku"), + (String) call.argument("accountId"), + (String) call.argument("obfuscatedProfileId"), + (String) call.argument("oldSku"), + (String) call.argument("purchaseToken"), + call.hasArgument("prorationMode") + ? (int) call.argument("prorationMode") + : ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY, + result); + break; + case InAppPurchasePlugin.MethodNames.QUERY_PURCHASES: + queryPurchases((String) call.argument("skuType"), result); + break; + case InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC: + queryPurchaseHistoryAsync((String) call.argument("skuType"), result); + break; + case InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC: + consumeAsync((String) call.argument("purchaseToken"), result); + break; + case InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE: + acknowledgePurchase((String) call.argument("purchaseToken"), result); + break; + default: + result.notImplemented(); + } + } + + private void endConnection(final MethodChannel.Result result) { + endBillingClientConnection(); + result.success(null); + } + + private void endBillingClientConnection() { + if (billingClient != null) { + billingClient.endConnection(); + billingClient = null; + } + } + + private void isReady(MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + + result.success(billingClient.isReady()); + } + + private void querySkuDetailsAsync( + final String skuType, final List skusList, final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + + SkuDetailsParams params = + SkuDetailsParams.newBuilder().setType(skuType).setSkusList(skusList).build(); + billingClient.querySkuDetailsAsync( + params, + new SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse( + BillingResult billingResult, List skuDetailsList) { + updateCachedSkus(skuDetailsList); + final Map skuDetailsResponse = new HashMap<>(); + skuDetailsResponse.put("billingResult", Translator.fromBillingResult(billingResult)); + skuDetailsResponse.put("skuDetailsList", fromSkuDetailsList(skuDetailsList)); + result.success(skuDetailsResponse); + } + }); + } + + private void launchBillingFlow( + String sku, + @Nullable String accountId, + @Nullable String obfuscatedProfileId, + @Nullable String oldSku, + @Nullable String purchaseToken, + int prorationMode, + MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + + SkuDetails skuDetails = cachedSkus.get(sku); + if (skuDetails == null) { + result.error( + "NOT_FOUND", + String.format( + "Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s", + sku, LOAD_SKU_DOC_URL), + null); + return; + } + + if (oldSku == null + && prorationMode != ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY) { + result.error( + "IN_APP_PURCHASE_REQUIRE_OLD_SKU", + "launchBillingFlow failed because oldSku is null. You must provide a valid oldSku in order to use a proration mode.", + null); + return; + } else if (oldSku != null && !cachedSkus.containsKey(oldSku)) { + result.error( + "IN_APP_PURCHASE_INVALID_OLD_SKU", + String.format( + "Details for sku %s are not available. It might because skus were not fetched prior to the call. Please fetch the skus first. An example of how to fetch the skus could be found here: %s", + oldSku, LOAD_SKU_DOC_URL), + null); + return; + } + + if (activity == null) { + result.error( + "ACTIVITY_UNAVAILABLE", + "Details for sku " + + sku + + " are not available. This method must be run with the app in foreground.", + null); + return; + } + + BillingFlowParams.Builder paramsBuilder = + BillingFlowParams.newBuilder().setSkuDetails(skuDetails); + if (accountId != null && !accountId.isEmpty()) { + paramsBuilder.setObfuscatedAccountId(accountId); + } + if (obfuscatedProfileId != null && !obfuscatedProfileId.isEmpty()) { + paramsBuilder.setObfuscatedProfileId(obfuscatedProfileId); + } + if (oldSku != null && !oldSku.isEmpty()) { + paramsBuilder.setOldSku(oldSku, purchaseToken); + } + // The proration mode value has to match one of the following declared in + // https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode + paramsBuilder.setReplaceSkusProrationMode(prorationMode); + result.success( + Translator.fromBillingResult( + billingClient.launchBillingFlow(activity, paramsBuilder.build()))); + } + + private void consumeAsync(String purchaseToken, final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + + ConsumeResponseListener listener = + new ConsumeResponseListener() { + @Override + public void onConsumeResponse(BillingResult billingResult, String outToken) { + result.success(Translator.fromBillingResult(billingResult)); + } + }; + ConsumeParams.Builder paramsBuilder = + ConsumeParams.newBuilder().setPurchaseToken(purchaseToken); + + ConsumeParams params = paramsBuilder.build(); + + billingClient.consumeAsync(params, listener); + } + + private void queryPurchases(String skuType, MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + + // Like in our connect call, consider the billing client responding a "success" here regardless + // of status code. + result.success(fromPurchasesResult(billingClient.queryPurchases(skuType))); + } + + private void queryPurchaseHistoryAsync(String skuType, final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + + billingClient.queryPurchaseHistoryAsync( + skuType, + new PurchaseHistoryResponseListener() { + @Override + public void onPurchaseHistoryResponse( + BillingResult billingResult, List purchasesList) { + final Map serialized = new HashMap<>(); + serialized.put("billingResult", Translator.fromBillingResult(billingResult)); + serialized.put( + "purchaseHistoryRecordList", fromPurchaseHistoryRecordList(purchasesList)); + result.success(serialized); + } + }); + } + + private void startConnection( + final int handle, final boolean enablePendingPurchases, final MethodChannel.Result result) { + if (billingClient == null) { + billingClient = + billingClientFactory.createBillingClient( + applicationContext, methodChannel, enablePendingPurchases); + } + + billingClient.startConnection( + new BillingClientStateListener() { + private boolean alreadyFinished = false; + + @Override + public void onBillingSetupFinished(BillingResult billingResult) { + if (alreadyFinished) { + Log.d(TAG, "Tried to call onBilllingSetupFinished multiple times."); + return; + } + alreadyFinished = true; + // Consider the fact that we've finished a success, leave it to the Dart side to + // validate the responseCode. + result.success(Translator.fromBillingResult(billingResult)); + } + + @Override + public void onBillingServiceDisconnected() { + final Map arguments = new HashMap<>(); + arguments.put("handle", handle); + methodChannel.invokeMethod(InAppPurchasePlugin.MethodNames.ON_DISCONNECT, arguments); + } + }); + } + + private void acknowledgePurchase(String purchaseToken, final MethodChannel.Result result) { + if (billingClientError(result)) { + return; + } + AcknowledgePurchaseParams params = + AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build(); + billingClient.acknowledgePurchase( + params, + new AcknowledgePurchaseResponseListener() { + @Override + public void onAcknowledgePurchaseResponse(BillingResult billingResult) { + result.success(Translator.fromBillingResult(billingResult)); + } + }); + } + + private void updateCachedSkus(@Nullable List skuDetailsList) { + if (skuDetailsList == null) { + return; + } + + for (SkuDetails skuDetails : skuDetailsList) { + cachedSkus.put(skuDetails.getSku(), skuDetails); + } + } + + private boolean billingClientError(MethodChannel.Result result) { + if (billingClient != null) { + return false; + } + + result.error("UNAVAILABLE", "BillingClient is unset. Try reconnecting.", null); + return true; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java new file mode 100644 index 000000000000..54c775d0ad0f --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/PluginPurchaseListener.java @@ -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. + +package io.flutter.plugins.inapppurchase; + +import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult; +import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; + +import androidx.annotation.Nullable; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.PurchasesUpdatedListener; +import io.flutter.plugin.common.MethodChannel; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class PluginPurchaseListener implements PurchasesUpdatedListener { + private final MethodChannel channel; + + PluginPurchaseListener(MethodChannel channel) { + this.channel = channel; + } + + @Override + public void onPurchasesUpdated(BillingResult billingResult, @Nullable List purchases) { + final Map callbackArgs = new HashMap<>(); + callbackArgs.put("billingResult", fromBillingResult(billingResult)); + callbackArgs.put("responseCode", billingResult.getResponseCode()); + callbackArgs.put("purchasesList", fromPurchasesList(purchases)); + channel.invokeMethod(InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED, callbackArgs); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java new file mode 100644 index 000000000000..37e30cbfed06 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/main/java/io/flutter/plugins/inapppurchase/Translator.java @@ -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. + +package io.flutter.plugins.inapppurchase; + +import androidx.annotation.Nullable; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchaseHistoryRecord; +import com.android.billingclient.api.SkuDetails; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +/** Handles serialization of {@link com.android.billingclient.api.BillingClient} related objects. */ +/*package*/ class Translator { + static HashMap fromSkuDetail(SkuDetails detail) { + HashMap info = new HashMap<>(); + info.put("title", detail.getTitle()); + info.put("description", detail.getDescription()); + info.put("freeTrialPeriod", detail.getFreeTrialPeriod()); + info.put("introductoryPrice", detail.getIntroductoryPrice()); + info.put("introductoryPriceAmountMicros", detail.getIntroductoryPriceAmountMicros()); + info.put("introductoryPriceCycles", detail.getIntroductoryPriceCycles()); + info.put("introductoryPricePeriod", detail.getIntroductoryPricePeriod()); + info.put("price", detail.getPrice()); + info.put("priceAmountMicros", detail.getPriceAmountMicros()); + info.put("priceCurrencyCode", detail.getPriceCurrencyCode()); + info.put("sku", detail.getSku()); + info.put("type", detail.getType()); + info.put("subscriptionPeriod", detail.getSubscriptionPeriod()); + info.put("originalPrice", detail.getOriginalPrice()); + info.put("originalPriceAmountMicros", detail.getOriginalPriceAmountMicros()); + return info; + } + + static List> fromSkuDetailsList( + @Nullable List skuDetailsList) { + if (skuDetailsList == null) { + return Collections.emptyList(); + } + + ArrayList> output = new ArrayList<>(); + for (SkuDetails detail : skuDetailsList) { + output.add(fromSkuDetail(detail)); + } + return output; + } + + static HashMap fromPurchase(Purchase purchase) { + HashMap info = new HashMap<>(); + info.put("orderId", purchase.getOrderId()); + info.put("packageName", purchase.getPackageName()); + info.put("purchaseTime", purchase.getPurchaseTime()); + info.put("purchaseToken", purchase.getPurchaseToken()); + info.put("signature", purchase.getSignature()); + info.put("sku", purchase.getSku()); + info.put("isAutoRenewing", purchase.isAutoRenewing()); + info.put("originalJson", purchase.getOriginalJson()); + info.put("developerPayload", purchase.getDeveloperPayload()); + info.put("isAcknowledged", purchase.isAcknowledged()); + info.put("purchaseState", purchase.getPurchaseState()); + return info; + } + + static HashMap fromPurchaseHistoryRecord( + PurchaseHistoryRecord purchaseHistoryRecord) { + HashMap info = new HashMap<>(); + info.put("purchaseTime", purchaseHistoryRecord.getPurchaseTime()); + info.put("purchaseToken", purchaseHistoryRecord.getPurchaseToken()); + info.put("signature", purchaseHistoryRecord.getSignature()); + info.put("sku", purchaseHistoryRecord.getSku()); + info.put("developerPayload", purchaseHistoryRecord.getDeveloperPayload()); + info.put("originalJson", purchaseHistoryRecord.getOriginalJson()); + return info; + } + + static List> fromPurchasesList(@Nullable List purchases) { + if (purchases == null) { + return Collections.emptyList(); + } + + List> serialized = new ArrayList<>(); + for (Purchase purchase : purchases) { + serialized.add(fromPurchase(purchase)); + } + return serialized; + } + + static List> fromPurchaseHistoryRecordList( + @Nullable List purchaseHistoryRecords) { + if (purchaseHistoryRecords == null) { + return Collections.emptyList(); + } + + List> serialized = new ArrayList<>(); + for (PurchaseHistoryRecord purchaseHistoryRecord : purchaseHistoryRecords) { + serialized.add(fromPurchaseHistoryRecord(purchaseHistoryRecord)); + } + return serialized; + } + + static HashMap fromPurchasesResult(PurchasesResult purchasesResult) { + HashMap info = new HashMap<>(); + info.put("responseCode", purchasesResult.getResponseCode()); + info.put("billingResult", fromBillingResult(purchasesResult.getBillingResult())); + info.put("purchasesList", fromPurchasesList(purchasesResult.getPurchasesList())); + return info; + } + + static HashMap fromBillingResult(BillingResult billingResult) { + HashMap info = new HashMap<>(); + info.put("responseCode", billingResult.getResponseCode()); + info.put("debugMessage", billingResult.getDebugMessage()); + return info; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/text/TextUtils.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/text/TextUtils.java new file mode 100644 index 000000000000..d997ae1dcaa0 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/text/TextUtils.java @@ -0,0 +1,11 @@ +// Copyright 2013 The Flutter Authors. 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.text; + +public class TextUtils { + public static boolean isEmpty(CharSequence str) { + return str == null || str.length() == 0; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/util/Log.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/util/Log.java new file mode 100644 index 000000000000..310b9ad89cdf --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/android/util/Log.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 android.util; + +public class Log { + public static int d(String tag, String msg) { + System.out.println("DEBUG: " + tag + ": " + msg); + return 0; + } + + public static int i(String tag, String msg) { + System.out.println("INFO: " + tag + ": " + msg); + return 0; + } + + public static int w(String tag, String msg) { + System.out.println("WARN: " + tag + ": " + msg); + return 0; + } + + public static int e(String tag, String msg) { + System.out.println("ERROR: " + tag + ": " + msg); + return 0; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java new file mode 100644 index 000000000000..bcee5428eac9 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/InAppPurchasePluginTest.java @@ -0,0 +1,40 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.inapppurchase; + +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.PluginRegistry; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +public class InAppPurchasePluginTest { + @Mock Activity activity; + @Mock Context context; + @Mock PluginRegistry.Registrar mockRegistrar; // For v1 embedding + @Mock BinaryMessenger mockMessenger; + @Mock Application mockApplication; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(mockRegistrar.activity()).thenReturn(activity); + when(mockRegistrar.messenger()).thenReturn(mockMessenger); + when(mockRegistrar.context()).thenReturn(context); + } + + @Test + public void registerWith_doNotCrashWhenRegisterContextIsActivity_V1Embedding() { + when(mockRegistrar.context()).thenReturn(activity); + when(activity.getApplicationContext()).thenReturn(mockApplication); + InAppPurchasePlugin.registerWith(mockRegistrar); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java new file mode 100644 index 000000000000..4d7a02220cf5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/MethodCallHandlerTest.java @@ -0,0 +1,812 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.inapppurchase; + +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ACKNOWLEDGE_PURCHASE; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.CONSUME_PURCHASE_ASYNC; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.END_CONNECTION; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.IS_READY; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.LAUNCH_BILLING_FLOW; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_DISCONNECT; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.ON_PURCHASES_UPDATED; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASES; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_PURCHASE_HISTORY_ASYNC; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.QUERY_SKU_DETAILS; +import static io.flutter.plugins.inapppurchase.InAppPurchasePlugin.MethodNames.START_CONNECTION; +import static io.flutter.plugins.inapppurchase.Translator.fromBillingResult; +import static io.flutter.plugins.inapppurchase.Translator.fromPurchaseHistoryRecordList; +import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesList; +import static io.flutter.plugins.inapppurchase.Translator.fromPurchasesResult; +import static io.flutter.plugins.inapppurchase.Translator.fromSkuDetailsList; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.Collections.unmodifiableList; +import static java.util.stream.Collectors.toList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.refEq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.AcknowledgePurchaseResponseListener; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClient.SkuType; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.ConsumeParams; +import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchaseHistoryRecord; +import com.android.billingclient.api.PurchaseHistoryResponseListener; +import com.android.billingclient.api.SkuDetails; +import com.android.billingclient.api.SkuDetailsParams; +import com.android.billingclient.api.SkuDetailsResponseListener; +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.Result; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.json.JSONException; +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 MethodCallHandlerTest { + private MethodCallHandlerImpl methodChannelHandler; + private BillingClientFactory factory; + @Mock BillingClient mockBillingClient; + @Mock MethodChannel mockMethodChannel; + @Spy Result result; + @Mock Activity activity; + @Mock Context context; + @Mock ActivityPluginBinding mockActivityPluginBinding; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + factory = + (@NonNull Context context, + @NonNull MethodChannel channel, + boolean enablePendingPurchases) -> mockBillingClient; + methodChannelHandler = new MethodCallHandlerImpl(activity, context, mockMethodChannel, factory); + when(mockActivityPluginBinding.getActivity()).thenReturn(activity); + } + + @Test + public void invalidMethod() { + MethodCall call = new MethodCall("invalid", null); + methodChannelHandler.onMethodCall(call, result); + verify(result, times(1)).notImplemented(); + } + + @Test + public void isReady_true() { + mockStartConnection(); + MethodCall call = new MethodCall(IS_READY, null); + when(mockBillingClient.isReady()).thenReturn(true); + methodChannelHandler.onMethodCall(call, result); + verify(result).success(true); + } + + @Test + public void isReady_false() { + mockStartConnection(); + MethodCall call = new MethodCall(IS_READY, null); + when(mockBillingClient.isReady()).thenReturn(false); + methodChannelHandler.onMethodCall(call, result); + verify(result).success(false); + } + + @Test + public void isReady_clientDisconnected() { + MethodCall disconnectCall = new MethodCall(END_CONNECTION, null); + methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class)); + MethodCall isReadyCall = new MethodCall(IS_READY, null); + + methodChannelHandler.onMethodCall(isReadyCall, result); + + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + verify(result, never()).success(any()); + } + + @Test + public void startConnection() { + ArgumentCaptor captor = mockStartConnection(); + verify(result, never()).success(any()); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + captor.getValue().onBillingSetupFinished(billingResult); + + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void startConnection_multipleCalls() { + Map arguments = new HashMap<>(); + arguments.put("handle", 1); + arguments.put("enablePendingPurchases", true); + MethodCall call = new MethodCall(START_CONNECTION, arguments); + ArgumentCaptor captor = + ArgumentCaptor.forClass(BillingClientStateListener.class); + doNothing().when(mockBillingClient).startConnection(captor.capture()); + + methodChannelHandler.onMethodCall(call, result); + verify(result, never()).success(any()); + BillingResult billingResult1 = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + BillingResult billingResult2 = + BillingResult.newBuilder() + .setResponseCode(200) + .setDebugMessage("dummy debug message") + .build(); + BillingResult billingResult3 = + BillingResult.newBuilder() + .setResponseCode(300) + .setDebugMessage("dummy debug message") + .build(); + + captor.getValue().onBillingSetupFinished(billingResult1); + captor.getValue().onBillingSetupFinished(billingResult2); + captor.getValue().onBillingSetupFinished(billingResult3); + + verify(result, times(1)).success(fromBillingResult(billingResult1)); + verify(result, times(1)).success(any()); + } + + @Test + public void endConnection() { + // Set up a connected BillingClient instance + final int disconnectCallbackHandle = 22; + Map arguments = new HashMap<>(); + arguments.put("handle", disconnectCallbackHandle); + arguments.put("enablePendingPurchases", true); + MethodCall connectCall = new MethodCall(START_CONNECTION, arguments); + ArgumentCaptor captor = + ArgumentCaptor.forClass(BillingClientStateListener.class); + doNothing().when(mockBillingClient).startConnection(captor.capture()); + methodChannelHandler.onMethodCall(connectCall, mock(Result.class)); + final BillingClientStateListener stateListener = captor.getValue(); + + // Disconnect the connected client + MethodCall disconnectCall = new MethodCall(END_CONNECTION, null); + methodChannelHandler.onMethodCall(disconnectCall, result); + + // Verify that the client is disconnected and that the OnDisconnect callback has + // been triggered + verify(result, times(1)).success(any()); + verify(mockBillingClient, times(1)).endConnection(); + stateListener.onBillingServiceDisconnected(); + Map expectedInvocation = new HashMap<>(); + expectedInvocation.put("handle", disconnectCallbackHandle); + verify(mockMethodChannel, times(1)).invokeMethod(ON_DISCONNECT, expectedInvocation); + } + + @Test + public void querySkuDetailsAsync() { + // Connect a billing client and set up the SKU query listeners + establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); + String skuType = BillingClient.SkuType.INAPP; + List skusList = asList("id1", "id2"); + HashMap arguments = new HashMap<>(); + arguments.put("skuType", skuType); + arguments.put("skusList", skusList); + MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments); + + // Query for SKU details + methodChannelHandler.onMethodCall(queryCall, result); + + // Assert the arguments were forwarded correctly to BillingClient + ArgumentCaptor paramCaptor = ArgumentCaptor.forClass(SkuDetailsParams.class); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(SkuDetailsResponseListener.class); + verify(mockBillingClient).querySkuDetailsAsync(paramCaptor.capture(), listenerCaptor.capture()); + assertEquals(paramCaptor.getValue().getSkuType(), skuType); + assertEquals(paramCaptor.getValue().getSkusList(), skusList); + + // Assert that we handed result BillingClient's response + int responseCode = 200; + List skuDetailsResponse = asList(buildSkuDetails("foo")); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); + ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); + verify(result).success(resultCaptor.capture()); + HashMap resultData = resultCaptor.getValue(); + assertEquals(resultData.get("billingResult"), fromBillingResult(billingResult)); + assertEquals(resultData.get("skuDetailsList"), fromSkuDetailsList(skuDetailsResponse)); + } + + @Test + public void querySkuDetailsAsync_clientDisconnected() { + // Disconnect the Billing client and prepare a querySkuDetails call + MethodCall disconnectCall = new MethodCall(END_CONNECTION, null); + methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class)); + String skuType = BillingClient.SkuType.INAPP; + List skusList = asList("id1", "id2"); + HashMap arguments = new HashMap<>(); + arguments.put("skuType", skuType); + arguments.put("skusList", skusList); + MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments); + + // Query for SKU details + methodChannelHandler.onMethodCall(queryCall, result); + + // Assert that we sent an error back. + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + verify(result, never()).success(any()); + } + + // Test launchBillingFlow not crash if `accountId` is `null` + // Ideally, we should check if the `accountId` is null in the parameter; however, + // since PBL 3.0, the `accountId` variable is not public. + @Test + public void launchBillingFlow_null_AccountId_do_not_crash() { + // Fetch the sku details first and then prepare the launch billing flow call + String skuId = "foo"; + queryForSkus(singletonList(skuId)); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", null); + arguments.put("obfuscatedProfileId", null); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void launchBillingFlow_ok_null_OldSku() { + // Fetch the sku details first and then prepare the launch billing flow call + String skuId = "foo"; + String accountId = "account"; + queryForSkus(singletonList(skuId)); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", null); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + assertNull(params.getOldSku()); + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void launchBillingFlow_ok_null_Activity() { + methodChannelHandler.setActivity(null); + + // Fetch the sku details first and then prepare the launch billing flow call + String skuId = "foo"; + String accountId = "account"; + queryForSkus(singletonList(skuId)); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the response code to result + verify(result).error(contains("ACTIVITY_UNAVAILABLE"), contains("foreground"), any()); + verify(result, never()).success(any()); + } + + @Test + public void launchBillingFlow_ok_oldSku() { + // Fetch the sku details first and query the method call + String skuId = "foo"; + String accountId = "account"; + String oldSkuId = "oldFoo"; + queryForSkus(unmodifiableList(asList(skuId, oldSkuId))); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + assertEquals(params.getOldSku(), oldSkuId); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void launchBillingFlow_ok_AccountId() { + // Fetch the sku details first and query the method call + String skuId = "foo"; + String accountId = "account"; + queryForSkus(singletonList(skuId)); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void launchBillingFlow_ok_Proration() { + // Fetch the sku details first and query the method call + String skuId = "foo"; + String oldSkuId = "oldFoo"; + String purchaseToken = "purchaseTokenFoo"; + String accountId = "account"; + int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE; + queryForSkus(unmodifiableList(asList(skuId, oldSkuId))); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + arguments.put("purchaseToken", purchaseToken); + arguments.put("prorationMode", prorationMode); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Verify we pass the arguments to the billing flow + ArgumentCaptor billingFlowParamsCaptor = + ArgumentCaptor.forClass(BillingFlowParams.class); + verify(mockBillingClient).launchBillingFlow(any(), billingFlowParamsCaptor.capture()); + BillingFlowParams params = billingFlowParamsCaptor.getValue(); + assertEquals(params.getSku(), skuId); + assertEquals(params.getOldSku(), oldSkuId); + assertEquals(params.getOldSkuPurchaseToken(), purchaseToken); + assertEquals(params.getReplaceSkusProrationMode(), prorationMode); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void launchBillingFlow_ok_Proration_with_null_OldSku() { + // Fetch the sku details first and query the method call + String skuId = "foo"; + String accountId = "account"; + String queryOldSkuId = "oldFoo"; + String oldSkuId = null; + int prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE; + queryForSkus(unmodifiableList(asList(skuId, queryOldSkuId))); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + arguments.put("prorationMode", prorationMode); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + // Launch the billing flow + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(mockBillingClient.launchBillingFlow(any(), any())).thenReturn(billingResult); + methodChannelHandler.onMethodCall(launchCall, result); + + // Assert that we sent an error back. + verify(result) + .error( + contains("IN_APP_PURCHASE_REQUIRE_OLD_SKU"), + contains("launchBillingFlow failed because oldSku is null"), + any()); + verify(result, never()).success(any()); + } + + @Test + public void launchBillingFlow_clientDisconnected() { + // Prepare the launch call after disconnecting the client + MethodCall disconnectCall = new MethodCall(END_CONNECTION, null); + methodChannelHandler.onMethodCall(disconnectCall, mock(Result.class)); + String skuId = "foo"; + String accountId = "account"; + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + methodChannelHandler.onMethodCall(launchCall, result); + + // Assert that we sent an error back. + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + verify(result, never()).success(any()); + } + + @Test + public void launchBillingFlow_skuNotFound() { + // Try to launch the billing flow for a random sku ID + establishConnectedBillingClient(null, null); + String skuId = "foo"; + String accountId = "account"; + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + methodChannelHandler.onMethodCall(launchCall, result); + + // Assert that we sent an error back. + verify(result).error(contains("NOT_FOUND"), contains(skuId), any()); + verify(result, never()).success(any()); + } + + @Test + public void launchBillingFlow_oldSkuNotFound() { + // Try to launch the billing flow for a random sku ID + establishConnectedBillingClient(null, null); + String skuId = "foo"; + String accountId = "account"; + String oldSkuId = "oldSku"; + queryForSkus(singletonList(skuId)); + HashMap arguments = new HashMap<>(); + arguments.put("sku", skuId); + arguments.put("accountId", accountId); + arguments.put("oldSku", oldSkuId); + MethodCall launchCall = new MethodCall(LAUNCH_BILLING_FLOW, arguments); + + methodChannelHandler.onMethodCall(launchCall, result); + + // Assert that we sent an error back. + verify(result).error(contains("IN_APP_PURCHASE_INVALID_OLD_SKU"), contains(oldSkuId), any()); + verify(result, never()).success(any()); + } + + @Test + public void queryPurchases() { + establishConnectedBillingClient(null, null); + PurchasesResult purchasesResult = mock(PurchasesResult.class); + Purchase purchase = buildPurchase("foo"); + when(purchasesResult.getPurchasesList()).thenReturn(asList(purchase)); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + when(purchasesResult.getBillingResult()).thenReturn(billingResult); + when(mockBillingClient.queryPurchases(SkuType.INAPP)).thenReturn(purchasesResult); + + HashMap arguments = new HashMap<>(); + arguments.put("skuType", SkuType.INAPP); + methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES, arguments), result); + + // Verify we pass the response to result + ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(resultCaptor.capture()); + assertEquals(fromPurchasesResult(purchasesResult), resultCaptor.getValue()); + } + + @Test + public void queryPurchases_clientDisconnected() { + // Prepare the launch call after disconnecting the client + methodChannelHandler.onMethodCall(new MethodCall(END_CONNECTION, null), mock(Result.class)); + + HashMap arguments = new HashMap<>(); + arguments.put("skuType", SkuType.INAPP); + methodChannelHandler.onMethodCall(new MethodCall(QUERY_PURCHASES, arguments), result); + + // Assert that we sent an error back. + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + verify(result, never()).success(any()); + } + + @Test + public void queryPurchaseHistoryAsync() { + // Set up an established billing client and all our mocked responses + establishConnectedBillingClient(null, null); + ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + List purchasesList = asList(buildPurchaseHistoryRecord("foo")); + HashMap arguments = new HashMap<>(); + arguments.put("skuType", SkuType.INAPP); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(PurchaseHistoryResponseListener.class); + + methodChannelHandler.onMethodCall( + new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result); + + // Verify we pass the data to result + verify(mockBillingClient) + .queryPurchaseHistoryAsync(eq(SkuType.INAPP), listenerCaptor.capture()); + listenerCaptor.getValue().onPurchaseHistoryResponse(billingResult, purchasesList); + verify(result).success(resultCaptor.capture()); + HashMap resultData = resultCaptor.getValue(); + assertEquals(fromBillingResult(billingResult), resultData.get("billingResult")); + assertEquals( + fromPurchaseHistoryRecordList(purchasesList), resultData.get("purchaseHistoryRecordList")); + } + + @Test + public void queryPurchaseHistoryAsync_clientDisconnected() { + // Prepare the launch call after disconnecting the client + methodChannelHandler.onMethodCall(new MethodCall(END_CONNECTION, null), mock(Result.class)); + + HashMap arguments = new HashMap<>(); + arguments.put("skuType", SkuType.INAPP); + methodChannelHandler.onMethodCall( + new MethodCall(QUERY_PURCHASE_HISTORY_ASYNC, arguments), result); + + // Assert that we sent an error back. + verify(result).error(contains("UNAVAILABLE"), contains("BillingClient"), any()); + verify(result, never()).success(any()); + } + + @Test + public void onPurchasesUpdatedListener() { + PluginPurchaseListener listener = new PluginPurchaseListener(mockMethodChannel); + + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + List purchasesList = asList(buildPurchase("foo")); + ArgumentCaptor> resultCaptor = ArgumentCaptor.forClass(HashMap.class); + doNothing() + .when(mockMethodChannel) + .invokeMethod(eq(ON_PURCHASES_UPDATED), resultCaptor.capture()); + listener.onPurchasesUpdated(billingResult, purchasesList); + + HashMap resultData = resultCaptor.getValue(); + assertEquals(fromBillingResult(billingResult), resultData.get("billingResult")); + assertEquals(fromPurchasesList(purchasesList), resultData.get("purchasesList")); + } + + @Test + public void consumeAsync() { + establishConnectedBillingClient(null, null); + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(BillingResult.class); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + HashMap arguments = new HashMap<>(); + arguments.put("purchaseToken", "mockToken"); + arguments.put("developerPayload", "mockPayload"); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(ConsumeResponseListener.class); + + methodChannelHandler.onMethodCall(new MethodCall(CONSUME_PURCHASE_ASYNC, arguments), result); + + ConsumeParams params = ConsumeParams.newBuilder().setPurchaseToken("mockToken").build(); + + // Verify we pass the data to result + verify(mockBillingClient).consumeAsync(refEq(params), listenerCaptor.capture()); + + listenerCaptor.getValue().onConsumeResponse(billingResult, "mockToken"); + verify(result).success(resultCaptor.capture()); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void acknowledgePurchase() { + establishConnectedBillingClient(null, null); + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(BillingResult.class); + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + HashMap arguments = new HashMap<>(); + arguments.put("purchaseToken", "mockToken"); + arguments.put("developerPayload", "mockPayload"); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AcknowledgePurchaseResponseListener.class); + + methodChannelHandler.onMethodCall(new MethodCall(ACKNOWLEDGE_PURCHASE, arguments), result); + + AcknowledgePurchaseParams params = + AcknowledgePurchaseParams.newBuilder().setPurchaseToken("mockToken").build(); + + // Verify we pass the data to result + verify(mockBillingClient).acknowledgePurchase(refEq(params), listenerCaptor.capture()); + + listenerCaptor.getValue().onAcknowledgePurchaseResponse(billingResult); + verify(result).success(resultCaptor.capture()); + + // Verify we pass the response code to result + verify(result, never()).error(any(), any(), any()); + verify(result, times(1)).success(fromBillingResult(billingResult)); + } + + @Test + public void endConnection_if_activity_dettached() { + InAppPurchasePlugin plugin = new InAppPurchasePlugin(); + plugin.setMethodCallHandler(methodChannelHandler); + mockStartConnection(); + plugin.onDetachedFromActivity(); + verify(mockBillingClient).endConnection(); + } + + private ArgumentCaptor mockStartConnection() { + Map arguments = new HashMap<>(); + arguments.put("handle", 1); + arguments.put("enablePendingPurchases", true); + MethodCall call = new MethodCall(START_CONNECTION, arguments); + ArgumentCaptor captor = + ArgumentCaptor.forClass(BillingClientStateListener.class); + doNothing().when(mockBillingClient).startConnection(captor.capture()); + + methodChannelHandler.onMethodCall(call, result); + return captor; + } + + private void establishConnectedBillingClient( + @Nullable Map arguments, @Nullable Result result) { + if (arguments == null) { + arguments = new HashMap<>(); + arguments.put("handle", 1); + arguments.put("enablePendingPurchases", true); + } + if (result == null) { + result = mock(Result.class); + } + + MethodCall connectCall = new MethodCall(START_CONNECTION, arguments); + methodChannelHandler.onMethodCall(connectCall, result); + } + + private void queryForSkus(List skusList) { + // Set up the query method call + establishConnectedBillingClient(/* arguments= */ null, /* result= */ null); + HashMap arguments = new HashMap<>(); + String skuType = SkuType.INAPP; + arguments.put("skuType", skuType); + arguments.put("skusList", skusList); + MethodCall queryCall = new MethodCall(QUERY_SKU_DETAILS, arguments); + + // Call the method. + methodChannelHandler.onMethodCall(queryCall, mock(Result.class)); + + // Respond to the call with a matching set of Sku details. + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(SkuDetailsResponseListener.class); + verify(mockBillingClient).querySkuDetailsAsync(any(), listenerCaptor.capture()); + List skuDetailsResponse = + skusList.stream().map(this::buildSkuDetails).collect(toList()); + + BillingResult billingResult = + BillingResult.newBuilder() + .setResponseCode(100) + .setDebugMessage("dummy debug message") + .build(); + listenerCaptor.getValue().onSkuDetailsResponse(billingResult, skuDetailsResponse); + } + + private SkuDetails buildSkuDetails(String id) { + String json = + String.format( + "{\"packageName\": \"dummyPackageName\",\"productId\":\"%s\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}", + id); + SkuDetails details = null; + try { + details = new SkuDetails(json); + } catch (JSONException e) { + fail("buildSkuDetails failed with JSONException " + e.toString()); + } + return details; + } + + private Purchase buildPurchase(String orderId) { + Purchase purchase = mock(Purchase.class); + when(purchase.getOrderId()).thenReturn(orderId); + return purchase; + } + + private PurchaseHistoryRecord buildPurchaseHistoryRecord(String purchaseToken) { + PurchaseHistoryRecord purchase = mock(PurchaseHistoryRecord.class); + when(purchase.getPurchaseToken()).thenReturn(purchaseToken); + return purchase; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java new file mode 100644 index 000000000000..47147e772bce --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/android/src/test/java/io/flutter/plugins/inapppurchase/TranslatorTest.java @@ -0,0 +1,213 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.inapppurchase; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchaseHistoryRecord; +import com.android.billingclient.api.SkuDetails; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.json.JSONException; +import org.junit.Test; + +public class TranslatorTest { + private static final String SKU_DETAIL_EXAMPLE_JSON = + "{\"productId\":\"example\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}"; + private static final String PURCHASE_EXAMPLE_JSON = + "{\"orderId\":\"foo\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; + + @Test + public void fromSkuDetail() throws JSONException { + final SkuDetails expected = new SkuDetails(SKU_DETAIL_EXAMPLE_JSON); + + Map serialized = Translator.fromSkuDetail(expected); + + assertSerialized(expected, serialized); + } + + @Test + public void fromSkuDetailsList() throws JSONException { + final String SKU_DETAIL_EXAMPLE_2_JSON = + "{\"productId\":\"example2\",\"type\":\"inapp\",\"price\":\"$0.99\",\"price_amount_micros\":990000,\"price_currency_code\":\"USD\",\"title\":\"Example title\",\"description\":\"Example description.\",\"original_price\":\"$0.99\",\"original_price_micros\":990000}"; + final List expected = + Arrays.asList( + new SkuDetails(SKU_DETAIL_EXAMPLE_JSON), new SkuDetails(SKU_DETAIL_EXAMPLE_2_JSON)); + + final List> serialized = Translator.fromSkuDetailsList(expected); + + assertEquals(expected.size(), serialized.size()); + assertSerialized(expected.get(0), serialized.get(0)); + assertSerialized(expected.get(1), serialized.get(1)); + } + + @Test + public void fromSkuDetailsList_null() { + assertEquals(Collections.emptyList(), Translator.fromSkuDetailsList(null)); + } + + @Test + public void fromPurchase() throws JSONException { + final Purchase expected = new Purchase(PURCHASE_EXAMPLE_JSON, "signature"); + assertSerialized(expected, Translator.fromPurchase(expected)); + } + + @Test + public void fromPurchaseHistoryRecord() throws JSONException { + final PurchaseHistoryRecord expected = + new PurchaseHistoryRecord(PURCHASE_EXAMPLE_JSON, "signature"); + assertSerialized(expected, Translator.fromPurchaseHistoryRecord(expected)); + } + + @Test + public void fromPurchasesHistoryRecordList() throws JSONException { + final String purchase2Json = + "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; + final String signature = "signature"; + final List expected = + Arrays.asList( + new PurchaseHistoryRecord(PURCHASE_EXAMPLE_JSON, signature), + new PurchaseHistoryRecord(purchase2Json, signature)); + + final List> serialized = + Translator.fromPurchaseHistoryRecordList(expected); + + assertEquals(expected.size(), serialized.size()); + assertSerialized(expected.get(0), serialized.get(0)); + assertSerialized(expected.get(1), serialized.get(1)); + } + + @Test + public void fromPurchasesHistoryRecordList_null() { + assertEquals(Collections.emptyList(), Translator.fromPurchaseHistoryRecordList(null)); + } + + @Test + public void fromPurchasesList() throws JSONException { + final String purchase2Json = + "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; + final String signature = "signature"; + final List expected = + Arrays.asList( + new Purchase(PURCHASE_EXAMPLE_JSON, signature), new Purchase(purchase2Json, signature)); + + final List> serialized = Translator.fromPurchasesList(expected); + + assertEquals(expected.size(), serialized.size()); + assertSerialized(expected.get(0), serialized.get(0)); + assertSerialized(expected.get(1), serialized.get(1)); + } + + @Test + public void fromPurchasesList_null() { + assertEquals(Collections.emptyList(), Translator.fromPurchasesList(null)); + } + + @Test + public void fromPurchasesResult() throws JSONException { + PurchasesResult result = mock(PurchasesResult.class); + final String purchase2Json = + "{\"orderId\":\"foo2\",\"packageName\":\"bar\",\"productId\":\"consumable\",\"purchaseTime\":11111111,\"purchaseState\":0,\"purchaseToken\":\"baz\",\"developerPayload\":\"dummy payload\",\"isAcknowledged\":\"true\"}"; + final String signature = "signature"; + final List expectedPurchases = + Arrays.asList( + new Purchase(PURCHASE_EXAMPLE_JSON, signature), new Purchase(purchase2Json, signature)); + when(result.getPurchasesList()).thenReturn(expectedPurchases); + when(result.getResponseCode()).thenReturn(BillingClient.BillingResponseCode.OK); + BillingResult newBillingResult = + BillingResult.newBuilder() + .setDebugMessage("dummy debug message") + .setResponseCode(BillingClient.BillingResponseCode.OK) + .build(); + when(result.getBillingResult()).thenReturn(newBillingResult); + final HashMap serialized = Translator.fromPurchasesResult(result); + + assertEquals(BillingClient.BillingResponseCode.OK, serialized.get("responseCode")); + List> serializedPurchases = + (List>) serialized.get("purchasesList"); + assertEquals(expectedPurchases.size(), serializedPurchases.size()); + assertSerialized(expectedPurchases.get(0), serializedPurchases.get(0)); + assertSerialized(expectedPurchases.get(1), serializedPurchases.get(1)); + + Map billingResultMap = (Map) serialized.get("billingResult"); + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); + } + + @Test + public void fromBillingResult() throws JSONException { + BillingResult newBillingResult = + BillingResult.newBuilder() + .setDebugMessage("dummy debug message") + .setResponseCode(BillingClient.BillingResponseCode.OK) + .build(); + Map billingResultMap = Translator.fromBillingResult(newBillingResult); + + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); + } + + @Test + public void fromBillingResult_debugMessageNull() throws JSONException { + BillingResult newBillingResult = + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.OK).build(); + Map billingResultMap = Translator.fromBillingResult(newBillingResult); + + assertEquals(billingResultMap.get("responseCode"), newBillingResult.getResponseCode()); + assertEquals(billingResultMap.get("debugMessage"), newBillingResult.getDebugMessage()); + } + + private void assertSerialized(SkuDetails expected, Map serialized) { + assertEquals(expected.getDescription(), serialized.get("description")); + assertEquals(expected.getFreeTrialPeriod(), serialized.get("freeTrialPeriod")); + assertEquals(expected.getIntroductoryPrice(), serialized.get("introductoryPrice")); + assertEquals( + expected.getIntroductoryPriceAmountMicros(), + serialized.get("introductoryPriceAmountMicros")); + assertEquals(expected.getIntroductoryPriceCycles(), serialized.get("introductoryPriceCycles")); + assertEquals(expected.getIntroductoryPricePeriod(), serialized.get("introductoryPricePeriod")); + assertEquals(expected.getPrice(), serialized.get("price")); + assertEquals(expected.getPriceAmountMicros(), serialized.get("priceAmountMicros")); + assertEquals(expected.getPriceCurrencyCode(), serialized.get("priceCurrencyCode")); + assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getSubscriptionPeriod(), serialized.get("subscriptionPeriod")); + assertEquals(expected.getTitle(), serialized.get("title")); + assertEquals(expected.getType(), serialized.get("type")); + assertEquals(expected.getOriginalPrice(), serialized.get("originalPrice")); + assertEquals( + expected.getOriginalPriceAmountMicros(), serialized.get("originalPriceAmountMicros")); + } + + private void assertSerialized(Purchase expected, Map serialized) { + assertEquals(expected.getOrderId(), serialized.get("orderId")); + assertEquals(expected.getPackageName(), serialized.get("packageName")); + assertEquals(expected.getPurchaseTime(), serialized.get("purchaseTime")); + assertEquals(expected.getPurchaseToken(), serialized.get("purchaseToken")); + assertEquals(expected.getSignature(), serialized.get("signature")); + assertEquals(expected.getOriginalJson(), serialized.get("originalJson")); + assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); + assertEquals(expected.isAcknowledged(), serialized.get("isAcknowledged")); + assertEquals(expected.getPurchaseState(), serialized.get("purchaseState")); + } + + private void assertSerialized(PurchaseHistoryRecord expected, Map serialized) { + assertEquals(expected.getPurchaseTime(), serialized.get("purchaseTime")); + assertEquals(expected.getPurchaseToken(), serialized.get("purchaseToken")); + assertEquals(expected.getSignature(), serialized.get("signature")); + assertEquals(expected.getOriginalJson(), serialized.get("originalJson")); + assertEquals(expected.getSku(), serialized.get("sku")); + assertEquals(expected.getDeveloperPayload(), serialized.get("developerPayload")); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/build.yaml b/packages/in_app_purchase/in_app_purchase_android/build.yaml new file mode 100644 index 000000000000..e15cf14b85fd --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + json_serializable: + options: + any_map: true + create_to_json: true diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.dart new file mode 100644 index 000000000000..1dac19f825b8 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/billing_client_wrappers.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/billing_client_wrappers/billing_client_wrapper.dart'; +export 'src/billing_client_wrappers/purchase_wrapper.dart'; +export 'src/billing_client_wrappers/sku_details_wrapper.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart b/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart new file mode 100644 index 000000000000..9d74a562b272 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/in_app_purchase_android.dart @@ -0,0 +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. + +export 'src/in_app_purchase_android_platform.dart'; +export 'src/types/types.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/README.md b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/README.md new file mode 100644 index 000000000000..54e76b528b48 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/README.md @@ -0,0 +1,6 @@ +# billing_client_wrappers + +This exposes a way Dart endpoints through to [Google Play Billing +Library](https://developer.android.com/google/play/billing/billing_library_overview). +Can be used as an alternative to +[in_app_purchase](../in_app_purchase/README.md). \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart new file mode 100644 index 000000000000..1f43b3a8fbdd --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/billing_client_wrapper.dart @@ -0,0 +1,448 @@ +// Copyright 2013 The Flutter Authors. 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 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import '../../billing_client_wrappers.dart'; +import '../channel.dart'; +import 'purchase_wrapper.dart'; +import 'sku_details_wrapper.dart'; +import 'enum_converters.dart'; + +/// Method identifier for the OnPurchaseUpdated method channel method. +@visibleForTesting +const String kOnPurchasesUpdated = + 'PurchasesUpdatedListener#onPurchasesUpdated(int, List)'; +const String _kOnBillingServiceDisconnected = + 'BillingClientStateListener#onBillingServiceDisconnected()'; + +/// Callback triggered by Play in response to purchase activity. +/// +/// This callback is triggered in response to all purchase activity while an +/// instance of `BillingClient` is active. This includes purchases initiated by +/// the app ([BillingClient.launchBillingFlow]) as well as purchases made in +/// Play itself while this app is open. +/// +/// This does not provide any hooks for purchases made in the past. See +/// [BillingClient.queryPurchases] and [BillingClient.queryPurchaseHistory]. +/// +/// All purchase information should also be verified manually, with your server +/// if at all possible. See ["Verify a +/// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). +/// +/// Wraps a +/// [`PurchasesUpdatedListener`](https://developer.android.com/reference/com/android/billingclient/api/PurchasesUpdatedListener.html). +typedef void PurchasesUpdatedListener(PurchasesResultWrapper purchasesResult); + +/// This class can be used directly instead of [InAppPurchaseConnection] to call +/// Play-specific billing APIs. +/// +/// Wraps a +/// [`com.android.billingclient.api.BillingClient`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient) +/// instance. +/// +/// +/// In general this API conforms to the Java +/// `com.android.billingclient.api.BillingClient` API as much as possible, with +/// some minor changes to account for language differences. Callbacks have been +/// converted to futures where appropriate. +class BillingClient { + bool _enablePendingPurchases = false; + + /// Creates a billing client. + BillingClient(PurchasesUpdatedListener onPurchasesUpdated) { + channel.setMethodCallHandler(callHandler); + _callbacks[kOnPurchasesUpdated] = [onPurchasesUpdated]; + } + + // Occasionally methods in the native layer require a Dart callback to be + // triggered in response to a Java callback. For example, + // [startConnection] registers an [OnBillingServiceDisconnected] callback. + // This list of names to callbacks is used to trigger Dart callbacks in + // response to those Java callbacks. Dart sends the Java layer a handle to the + // matching callback here to remember, and then once its twin is triggered it + // sends the handle back over the platform channel. We then access that handle + // in this array and call it in Dart code. See also [_callHandler]. + Map> _callbacks = >{}; + + /// Calls + /// [`BillingClient#isReady()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#isReady()) + /// to get the ready status of the BillingClient instance. + Future isReady() async { + final bool? ready = + await channel.invokeMethod('BillingClient#isReady()'); + return ready ?? false; + } + + /// Enable the [BillingClientWrapper] to handle pending purchases. + /// + /// Play requires that you call this method when initializing your application. + /// It is to acknowledge your application has been updated to support pending purchases. + /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) + /// for more details. + /// + /// Failure to call this method before any other method in the [startConnection] will throw an exception. + void enablePendingPurchases() { + _enablePendingPurchases = true; + } + + /// Calls + /// [`BillingClient#startConnection(BillingClientStateListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#startconnection) + /// to create and connect a `BillingClient` instance. + /// + /// [onBillingServiceConnected] has been converted from a callback parameter + /// to the Future result returned by this function. This returns the + /// `BillingClient.BillingResultWrapper` describing the connection result. + /// + /// This triggers the creation of a new `BillingClient` instance in Java if + /// one doesn't already exist. + Future startConnection( + {required OnBillingServiceDisconnected + onBillingServiceDisconnected}) async { + assert(_enablePendingPurchases, + 'enablePendingPurchases() must be called before calling startConnection'); + List disconnectCallbacks = + _callbacks[_kOnBillingServiceDisconnected] ??= []; + disconnectCallbacks.add(onBillingServiceDisconnected); + return BillingResultWrapper.fromJson((await channel + .invokeMapMethod( + "BillingClient#startConnection(BillingClientStateListener)", + { + 'handle': disconnectCallbacks.length - 1, + 'enablePendingPurchases': _enablePendingPurchases + })) ?? + {}); + } + + /// Calls + /// [`BillingClient#endConnection(BillingClientStateListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#endconnect + /// to disconnect a `BillingClient` instance. + /// + /// Will trigger the [OnBillingServiceDisconnected] callback passed to [startConnection]. + /// + /// This triggers the destruction of the `BillingClient` instance in Java. + Future endConnection() async { + return channel.invokeMethod("BillingClient#endConnection()", null); + } + + /// Returns a list of [SkuDetailsWrapper]s that have [SkuDetailsWrapper.sku] + /// in `skusList`, and [SkuDetailsWrapper.type] matching `skuType`. + /// + /// Calls through to [`BillingClient#querySkuDetailsAsync(SkuDetailsParams, + /// SkuDetailsResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querySkuDetailsAsync(com.android.billingclient.api.SkuDetailsParams,%20com.android.billingclient.api.SkuDetailsResponseListener)) + /// Instead of taking a callback parameter, it returns a Future + /// [SkuDetailsResponseWrapper]. It also takes the values of + /// `SkuDetailsParams` as direct arguments instead of requiring it constructed + /// and passed in as a class. + Future querySkuDetails( + {required SkuType skuType, required List skusList}) async { + final Map arguments = { + 'skuType': SkuTypeConverter().toJson(skuType), + 'skusList': skusList + }; + return SkuDetailsResponseWrapper.fromJson((await channel.invokeMapMethod< + String, dynamic>( + 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)', + arguments)) ?? + {}); + } + + /// Attempt to launch the Play Billing Flow for a given [skuDetails]. + /// + /// The [skuDetails] needs to have already been fetched in a [querySkuDetails] + /// call. The [accountId] is an optional hashed string associated with the user + /// that's unique to your app. It's used by Google to detect unusual behavior. + /// Do not pass in a cleartext [accountId], and do not use this field to store any Personally Identifiable Information (PII) + /// such as emails in cleartext. Attempting to store PII in this field will result in purchases being blocked. + /// Google Play recommends that you use either encryption or a one-way hash to generate an obfuscated identifier to send to Google Play. + /// + /// Specifies an optional [obfuscatedProfileId] that is uniquely associated with the user's profile in your app. + /// Some applications allow users to have multiple profiles within a single account. Use this method to send the user's profile identifier to Google. + /// Setting this field requests the user's obfuscated account id. + /// + /// Calling this attemps to show the Google Play purchase UI. The user is free + /// to complete the transaction there. + /// + /// This method returns a [BillingResultWrapper] representing the initial attempt + /// to show the Google Play billing flow. Actual purchase updates are + /// delivered via the [PurchasesUpdatedListener]. + /// + /// This method calls through to + /// [`BillingClient#launchBillingFlow`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#launchbillingflow). + /// It constructs a + /// [`BillingFlowParams`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams) + /// instance by [setting the given skuDetails](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder.html#setskudetails), + /// [the given accountId](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)) + /// and the [obfuscatedProfileId] (https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setobfuscatedprofileid). + /// + /// When this method is called to purchase a subscription, an optional `oldSku` + /// can be passed in. This will tell Google Play that rather than purchasing a new subscription, + /// the user needs to upgrade/downgrade the existing subscription. + /// The [oldSku](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setoldsku) and [purchaseToken] are the SKU id and purchase token that the user is upgrading or downgrading from. + /// [purchaseToken] must not be `null` if [oldSku] is not `null`. + /// The [prorationMode](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setreplaceskusprorationmode) is the mode of proration during subscription upgrade/downgrade. + /// This value will only be effective if the `oldSku` is also set. + Future launchBillingFlow( + {required String sku, + String? accountId, + String? obfuscatedProfileId, + String? oldSku, + String? purchaseToken, + ProrationMode? prorationMode}) async { + assert(sku != null); + assert((oldSku == null) == (purchaseToken == null), + 'oldSku and purchaseToken must both be set, or both be null.'); + final Map arguments = { + 'sku': sku, + 'accountId': accountId, + 'obfuscatedProfileId': obfuscatedProfileId, + 'oldSku': oldSku, + 'purchaseToken': purchaseToken, + 'prorationMode': ProrationModeConverter().toJson(prorationMode ?? + ProrationMode.unknownSubscriptionUpgradeDowngradePolicy) + }; + return BillingResultWrapper.fromJson( + (await channel.invokeMapMethod( + 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)', + arguments)) ?? + {}); + } + + /// Fetches recent purchases for the given [SkuType]. + /// + /// Unlike [queryPurchaseHistory], This does not make a network request and + /// does not return items that are no longer owned. + /// + /// All purchase information should also be verified manually, with your + /// server if at all possible. See ["Verify a + /// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). + /// + /// This wraps [`BillingClient#queryPurchases(String + /// skutype)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchases). + Future queryPurchases(SkuType skuType) async { + assert(skuType != null); + return PurchasesResultWrapper.fromJson((await channel + .invokeMapMethod( + 'BillingClient#queryPurchases(String)', { + 'skuType': SkuTypeConverter().toJson(skuType) + })) ?? + {}); + } + + /// Fetches purchase history for the given [SkuType]. + /// + /// Unlike [queryPurchases], this makes a network request via Play and returns + /// the most recent purchase for each [SkuDetailsWrapper] of the given + /// [SkuType] even if the item is no longer owned. + /// + /// All purchase information should also be verified manually, with your + /// server if at all possible. See ["Verify a + /// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). + /// + /// This wraps [`BillingClient#queryPurchaseHistoryAsync(String skuType, + /// PurchaseHistoryResponseListener + /// listener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient#querypurchasehistoryasync). + Future queryPurchaseHistory(SkuType skuType) async { + assert(skuType != null); + return PurchasesHistoryResult.fromJson((await channel.invokeMapMethod< + String, dynamic>( + 'BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)', + { + 'skuType': SkuTypeConverter().toJson(skuType) + })) ?? + {}); + } + + /// Consumes a given in-app product. + /// + /// Consuming can only be done on an item that's owned, and as a result of consumption, the user will no longer own it. + /// Consumption is done asynchronously. The method returns a Future containing a [BillingResultWrapper]. + /// + /// This wraps [`BillingClient#consumeAsync(String, ConsumeResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#consumeAsync(java.lang.String,%20com.android.billingclient.api.ConsumeResponseListener)) + Future consumeAsync(String purchaseToken) async { + assert(purchaseToken != null); + return BillingResultWrapper.fromJson((await channel + .invokeMapMethod( + 'BillingClient#consumeAsync(String, ConsumeResponseListener)', + { + 'purchaseToken': purchaseToken, + })) ?? + {}); + } + + /// Acknowledge an in-app purchase. + /// + /// The developer must acknowledge all in-app purchases after they have been granted to the user. + /// If this doesn't happen within three days of the purchase, the purchase will be refunded. + /// + /// Consumables are already implicitly acknowledged by calls to [consumeAsync] and + /// do not need to be explicitly acknowledged by using this method. + /// However this method can be called for them in order to explicitly acknowledge them if desired. + /// + /// Be sure to only acknowledge a purchase after it has been granted to the user. + /// [PurchaseWrapper.purchaseState] should be [PurchaseStateWrapper.purchased] and + /// the purchase should be validated. See [Verify a purchase](https://developer.android.com/google/play/billing/billing_library_overview#Verify) on verifying purchases. + /// + /// Please refer to [acknowledge](https://developer.android.com/google/play/billing/billing_library_overview#acknowledge) for more + /// details. + /// + /// This wraps [`BillingClient#acknowledgePurchase(String, AcknowledgePurchaseResponseListener)`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.html#acknowledgePurchase(com.android.billingclient.api.AcknowledgePurchaseParams,%20com.android.billingclient.api.AcknowledgePurchaseResponseListener)) + Future acknowledgePurchase(String purchaseToken) async { + assert(purchaseToken != null); + return BillingResultWrapper.fromJson((await channel.invokeMapMethod( + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)', + { + 'purchaseToken': purchaseToken, + })) ?? + {}); + } + + /// The method call handler for [channel]. + @visibleForTesting + Future callHandler(MethodCall call) async { + switch (call.method) { + case kOnPurchasesUpdated: + // The purchases updated listener is a singleton. + assert(_callbacks[kOnPurchasesUpdated]!.length == 1); + final PurchasesUpdatedListener listener = + _callbacks[kOnPurchasesUpdated]!.first as PurchasesUpdatedListener; + listener(PurchasesResultWrapper.fromJson( + call.arguments.cast())); + break; + case _kOnBillingServiceDisconnected: + final int handle = call.arguments['handle']; + await _callbacks[_kOnBillingServiceDisconnected]![handle](); + break; + } + } +} + +/// Callback triggered when the [BillingClientWrapper] is disconnected. +/// +/// Wraps +/// [`com.android.billingclient.api.BillingClientStateListener.onServiceDisconnected()`](https://developer.android.com/reference/com/android/billingclient/api/BillingClientStateListener.html#onBillingServiceDisconnected()) +/// to call back on `BillingClient` disconnect. +typedef void OnBillingServiceDisconnected(); + +/// Possible `BillingClient` response statuses. +/// +/// Wraps +/// [`BillingClient.BillingResponse`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponse). +/// See the `BillingResponse` docs for more explanation of the different +/// constants. +enum BillingResponse { + // WARNING: Changes to this class need to be reflected in our generated code. + // Run `flutter packages pub run build_runner watch` to rebuild and watch for + // further changes. + + /// The request has reached the maximum timeout before Google Play responds. + @JsonValue(-3) + serviceTimeout, + + /// The requested feature is not supported by Play Store on the current device. + @JsonValue(-2) + featureNotSupported, + + /// The play Store service is not connected now - potentially transient state. + @JsonValue(-1) + serviceDisconnected, + + /// Success. + @JsonValue(0) + ok, + + /// The user pressed back or canceled a dialog. + @JsonValue(1) + userCanceled, + + /// The network connection is down. + @JsonValue(2) + serviceUnavailable, + + /// The billing API version is not supported for the type requested. + @JsonValue(3) + billingUnavailable, + + /// The requested product is not available for purchase. + @JsonValue(4) + itemUnavailable, + + /// Invalid arguments provided to the API. + @JsonValue(5) + developerError, + + /// Fatal error during the API action. + @JsonValue(6) + error, + + /// Failure to purchase since item is already owned. + @JsonValue(7) + itemAlreadyOwned, + + /// Failure to consume since item is not owned. + @JsonValue(8) + itemNotOwned, +} + +/// Enum representing potential [SkuDetailsWrapper.type]s. +/// +/// Wraps +/// [`BillingClient.SkuType`](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.SkuType) +/// See the linked documentation for an explanation of the different constants. +enum SkuType { + // WARNING: Changes to this class need to be reflected in our generated code. + // Run `flutter packages pub run build_runner watch` to rebuild and watch for + // further changes. + + /// A one time product. Acquired in a single transaction. + @JsonValue('inapp') + inapp, + + /// A product requiring a recurring charge over time. + @JsonValue('subs') + subs, +} + +/// Enum representing the proration mode. +/// +/// When upgrading or downgrading a subscription, set this mode to provide details +/// about the proration that will be applied when the subscription changes. +/// +/// Wraps [`BillingFlowParams.ProrationMode`](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProrationMode) +/// See the linked documentation for an explanation of the different constants. +enum ProrationMode { +// WARNING: Changes to this class need to be reflected in our generated code. +// Run `flutter packages pub run build_runner watch` to rebuild and watch for +// further changes. + + /// Unknown upgrade or downgrade policy. + @JsonValue(0) + unknownSubscriptionUpgradeDowngradePolicy, + + /// Replacement takes effect immediately, and the remaining time will be prorated and credited to the user. + /// + /// This is the current default behavior. + @JsonValue(1) + immediateWithTimeProration, + + /// Replacement takes effect immediately, and the billing cycle remains the same. + /// + /// The price for the remaining period will be charged. + /// This option is only available for subscription upgrade. + @JsonValue(2) + immediateAndChargeProratedPrice, + + /// Replacement takes effect immediately, and the new price will be charged on next recurrence time. + /// + /// The billing cycle stays the same. + @JsonValue(3) + immediateWithoutProration, + + /// Replacement takes effect when the old plan expires, and the new price will be charged at the same time. + @JsonValue(4) + deferred, +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart new file mode 100644 index 000000000000..46d6843af846 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.dart @@ -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 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../../billing_client_wrappers.dart'; + +part 'enum_converters.g.dart'; + +/// Serializer for [BillingResponse]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@BillingResponseConverter()`. +class BillingResponseConverter implements JsonConverter { + /// Default const constructor. + const BillingResponseConverter(); + + @override + BillingResponse fromJson(int? json) { + if (json == null) { + return BillingResponse.error; + } + return _$enumDecode( + _$BillingResponseEnumMap.cast(), json); + } + + @override + int toJson(BillingResponse object) => _$BillingResponseEnumMap[object]!; +} + +/// Serializer for [SkuType]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@SkuTypeConverter()`. +class SkuTypeConverter implements JsonConverter { + /// Default const constructor. + const SkuTypeConverter(); + + @override + SkuType fromJson(String? json) { + if (json == null) { + return SkuType.inapp; + } + return _$enumDecode( + _$SkuTypeEnumMap.cast(), json); + } + + @override + String toJson(SkuType object) => _$SkuTypeEnumMap[object]!; +} + +/// Serializer for [ProrationMode]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@ProrationModeConverter()`. +class ProrationModeConverter implements JsonConverter { + /// Default const constructor. + const ProrationModeConverter(); + + @override + ProrationMode fromJson(int? json) { + if (json == null) { + return ProrationMode.unknownSubscriptionUpgradeDowngradePolicy; + } + return _$enumDecode( + _$ProrationModeEnumMap.cast(), json); + } + + @override + int toJson(ProrationMode object) => _$ProrationModeEnumMap[object]!; +} + +// Define a class so we generate serializer helper methods for the enums +@JsonSerializable() +class _SerializedEnums { + late BillingResponse response; + late SkuType type; + late PurchaseStateWrapper purchaseState; + late ProrationMode prorationMode; +} + +/// Serializer for [PurchaseStateWrapper]. +/// +/// Use these in `@JsonSerializable()` classes by annotating them with +/// `@PurchaseStateConverter()`. +class PurchaseStateConverter + implements JsonConverter { + /// Default const constructor. + const PurchaseStateConverter(); + + @override + PurchaseStateWrapper fromJson(int? json) { + if (json == null) { + return PurchaseStateWrapper.unspecified_state; + } + return _$enumDecode( + _$PurchaseStateWrapperEnumMap.cast(), + json); + } + + @override + int toJson(PurchaseStateWrapper object) => + _$PurchaseStateWrapperEnumMap[object]!; + + /// Converts the purchase state stored in `object` to a [PurchaseStatus]. + /// + /// [PurchaseStateWrapper.unspecified_state] is mapped to [PurchaseStatus.error]. + PurchaseStatus toPurchaseStatus(PurchaseStateWrapper object) { + switch (object) { + case PurchaseStateWrapper.pending: + return PurchaseStatus.pending; + case PurchaseStateWrapper.purchased: + return PurchaseStatus.purchased; + case PurchaseStateWrapper.unspecified_state: + return PurchaseStatus.error; + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart new file mode 100644 index 000000000000..4186a2a24252 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/enum_converters.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'enum_converters.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SerializedEnums _$_SerializedEnumsFromJson(Map json) { + return _SerializedEnums() + ..response = _$enumDecode(_$BillingResponseEnumMap, json['response']) + ..type = _$enumDecode(_$SkuTypeEnumMap, json['type']) + ..purchaseState = + _$enumDecode(_$PurchaseStateWrapperEnumMap, json['purchaseState']) + ..prorationMode = + _$enumDecode(_$ProrationModeEnumMap, json['prorationMode']); +} + +Map _$_SerializedEnumsToJson(_SerializedEnums instance) => + { + 'response': _$BillingResponseEnumMap[instance.response], + 'type': _$SkuTypeEnumMap[instance.type], + 'purchaseState': _$PurchaseStateWrapperEnumMap[instance.purchaseState], + 'prorationMode': _$ProrationModeEnumMap[instance.prorationMode], + }; + +K _$enumDecode( + Map enumValues, + Object? source, { + K? unknownValue, +}) { + if (source == null) { + throw ArgumentError( + 'A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}', + ); + } + + return enumValues.entries.singleWhere( + (e) => e.value == source, + orElse: () { + if (unknownValue == null) { + throw ArgumentError( + '`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}', + ); + } + return MapEntry(unknownValue, enumValues.values.first); + }, + ).key; +} + +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 _$PurchaseStateWrapperEnumMap = { + PurchaseStateWrapper.unspecified_state: 0, + PurchaseStateWrapper.purchased: 1, + PurchaseStateWrapper.pending: 2, +}; + +const _$ProrationModeEnumMap = { + ProrationMode.unknownSubscriptionUpgradeDowngradePolicy: 0, + ProrationMode.immediateWithTimeProration: 1, + ProrationMode.immediateAndChargeProratedPrice: 2, + ProrationMode.immediateWithoutProration: 3, + ProrationMode.deferred: 4, +}; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart new file mode 100644 index 000000000000..7ef089f4af8d --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.dart @@ -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. + +import 'dart:ui' show hashValues; +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'enum_converters.dart'; +import 'billing_client_wrapper.dart'; +import 'sku_details_wrapper.dart'; + +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'purchase_wrapper.g.dart'; + +/// Data structure representing a successful purchase. +/// +/// All purchase information should also be verified manually, with your +/// server if at all possible. See ["Verify a +/// purchase"](https://developer.android.com/google/play/billing/billing_library_overview#Verify). +/// +/// This wraps [`com.android.billlingclient.api.Purchase`](https://developer.android.com/reference/com/android/billingclient/api/Purchase) +@JsonSerializable() +@PurchaseStateConverter() +class PurchaseWrapper { + /// Creates a purchase wrapper with the given purchase details. + @visibleForTesting + PurchaseWrapper( + {required this.orderId, + required this.packageName, + required this.purchaseTime, + required this.purchaseToken, + required this.signature, + required this.sku, + required this.isAutoRenewing, + required this.originalJson, + this.developerPayload, + required this.isAcknowledged, + required this.purchaseState}); + + /// Factory for creating a [PurchaseWrapper] from a [Map] with the purchase details. + factory PurchaseWrapper.fromJson(Map map) => + _$PurchaseWrapperFromJson(map); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchaseWrapper typedOther = other as PurchaseWrapper; + return typedOther.orderId == orderId && + typedOther.packageName == packageName && + typedOther.purchaseTime == purchaseTime && + typedOther.purchaseToken == purchaseToken && + typedOther.signature == signature && + typedOther.sku == sku && + typedOther.isAutoRenewing == isAutoRenewing && + typedOther.originalJson == originalJson && + typedOther.isAcknowledged == isAcknowledged && + typedOther.purchaseState == purchaseState; + } + + @override + int get hashCode => hashValues( + orderId, + packageName, + purchaseTime, + purchaseToken, + signature, + sku, + isAutoRenewing, + originalJson, + isAcknowledged, + purchaseState); + + /// The unique ID for this purchase. Corresponds to the Google Payments order + /// ID. + @JsonKey(defaultValue: '') + final String orderId; + + /// The package name the purchase was made from. + @JsonKey(defaultValue: '') + final String packageName; + + /// When the purchase was made, as an epoch timestamp. + @JsonKey(defaultValue: 0) + final int purchaseTime; + + /// A unique ID for a given [SkuDetailsWrapper], user, and purchase. + @JsonKey(defaultValue: '') + final String purchaseToken; + + /// Signature of purchase data, signed with the developer's private key. Uses + /// RSASSA-PKCS1-v1_5. + @JsonKey(defaultValue: '') + final String signature; + + /// The product ID of this purchase. + @JsonKey(defaultValue: '') + final String sku; + + /// True for subscriptions that renew automatically. Does not apply to + /// [SkuType.inapp] products. + /// + /// For [SkuType.subs] this means that the subscription is canceled when it is + /// false. + /// + /// The value is `false` for [SkuType.inapp] products. + final bool isAutoRenewing; + + /// Details about this purchase, in JSON. + /// + /// This can be used verify a purchase. See ["Verify a purchase on a + /// device"](https://developer.android.com/google/play/billing/billing_library_overview#Verify-purchase-device). + /// Note though that verifying a purchase locally is inherently insecure (see + /// the article for more details). + @JsonKey(defaultValue: '') + final String originalJson; + + /// The payload specified by the developer when the purchase was acknowledged or consumed. + /// + /// The value is `null` if it wasn't specified when the purchase was acknowledged or consumed. + /// The `developerPayload` is removed from [BillingClientWrapper.acknowledgePurchase], [BillingClientWrapper.consumeAsync], [InAppPurchaseConnection.completePurchase], [InAppPurchaseConnection.consumePurchase] + /// after plugin version `0.5.0`. As a result, this will be `null` for new purchases that happen after updating to `0.5.0`. + final String? developerPayload; + + /// Whether the purchase has been acknowledged. + /// + /// A successful purchase has to be acknowledged within 3 days after the purchase via [BillingClient.acknowledgePurchase]. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + @JsonKey(defaultValue: false) + final bool isAcknowledged; + + /// Determines the current state of the purchase. + /// + /// [BillingClient.acknowledgePurchase] should only be called when the `purchaseState` is [PurchaseStateWrapper.purchased]. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + final PurchaseStateWrapper purchaseState; +} + +/// Data structure representing a purchase history record. +/// +/// This class includes a subset of fields in [PurchaseWrapper]. +/// +/// This wraps [`com.android.billlingclient.api.PurchaseHistoryRecord`](https://developer.android.com/reference/com/android/billingclient/api/PurchaseHistoryRecord) +/// +/// * See also: [BillingClient.queryPurchaseHistory] for obtaining a [PurchaseHistoryRecordWrapper]. +// We can optionally make [PurchaseWrapper] extend or implement [PurchaseHistoryRecordWrapper]. +// For now, we keep them separated classes to be consistent with Android's BillingClient implementation. +@JsonSerializable() +class PurchaseHistoryRecordWrapper { + /// Creates a [PurchaseHistoryRecordWrapper] with the given record details. + @visibleForTesting + PurchaseHistoryRecordWrapper({ + required this.purchaseTime, + required this.purchaseToken, + required this.signature, + required this.sku, + required this.originalJson, + required this.developerPayload, + }); + + /// Factory for creating a [PurchaseHistoryRecordWrapper] from a [Map] with the record details. + factory PurchaseHistoryRecordWrapper.fromJson(Map map) => + _$PurchaseHistoryRecordWrapperFromJson(map); + + /// When the purchase was made, as an epoch timestamp. + @JsonKey(defaultValue: 0) + final int purchaseTime; + + /// A unique ID for a given [SkuDetailsWrapper], user, and purchase. + @JsonKey(defaultValue: '') + final String purchaseToken; + + /// Signature of purchase data, signed with the developer's private key. Uses + /// RSASSA-PKCS1-v1_5. + @JsonKey(defaultValue: '') + final String signature; + + /// The product ID of this purchase. + @JsonKey(defaultValue: '') + final String sku; + + /// Details about this purchase, in JSON. + /// + /// This can be used verify a purchase. See ["Verify a purchase on a + /// device"](https://developer.android.com/google/play/billing/billing_library_overview#Verify-purchase-device). + /// Note though that verifying a purchase locally is inherently insecure (see + /// the article for more details). + @JsonKey(defaultValue: '') + final String originalJson; + + /// The payload specified by the developer when the purchase was acknowledged or consumed. + /// + /// The value is `null` if it wasn't specified when the purchase was acknowledged or consumed. + final String? developerPayload; + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchaseHistoryRecordWrapper typedOther = + other as PurchaseHistoryRecordWrapper; + return typedOther.purchaseTime == purchaseTime && + typedOther.purchaseToken == purchaseToken && + typedOther.signature == signature && + typedOther.sku == sku && + typedOther.originalJson == originalJson && + typedOther.developerPayload == developerPayload; + } + + @override + int get hashCode => hashValues(purchaseTime, purchaseToken, signature, sku, + originalJson, developerPayload); +} + +/// A data struct representing the result of a transaction. +/// +/// Contains a potentially empty list of [PurchaseWrapper]s, a [BillingResultWrapper] +/// that contains a detailed description of the status and a +/// [BillingResponse] to signify the overall state of the transaction. +/// +/// Wraps [`com.android.billingclient.api.Purchase.PurchasesResult`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchasesResult). +@JsonSerializable() +@BillingResponseConverter() +class PurchasesResultWrapper { + /// Creates a [PurchasesResultWrapper] with the given purchase result details. + PurchasesResultWrapper( + {required this.responseCode, + required this.billingResult, + required this.purchasesList}); + + /// Factory for creating a [PurchaseResultWrapper] from a [Map] with the result details. + factory PurchasesResultWrapper.fromJson(Map map) => + _$PurchasesResultWrapperFromJson(map); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchasesResultWrapper typedOther = other as PurchasesResultWrapper; + return typedOther.responseCode == responseCode && + typedOther.purchasesList == purchasesList && + typedOther.billingResult == billingResult; + } + + @override + int get hashCode => hashValues(billingResult, responseCode, purchasesList); + + /// The detailed description of the status of the operation. + final BillingResultWrapper billingResult; + + /// The status of the operation. + /// + /// This can represent either the status of the "query purchase history" half + /// of the operation and the "user made purchases" transaction itself. + final BillingResponse responseCode; + + /// The list of successful purchases made in this transaction. + /// + /// May be empty, especially if [responseCode] is not [BillingResponse.ok]. + @JsonKey(defaultValue: []) + final List purchasesList; +} + +/// A data struct representing the result of a purchase history. +/// +/// Contains a potentially empty list of [PurchaseHistoryRecordWrapper]s and a [BillingResultWrapper] +/// that contains a detailed description of the status. +@JsonSerializable() +@BillingResponseConverter() +class PurchasesHistoryResult { + /// Creates a [PurchasesHistoryResult] with the provided history. + PurchasesHistoryResult( + {required this.billingResult, required this.purchaseHistoryRecordList}); + + /// Factory for creating a [PurchasesHistoryResult] from a [Map] with the history result details. + factory PurchasesHistoryResult.fromJson(Map map) => + _$PurchasesHistoryResultFromJson(map); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + if (other.runtimeType != runtimeType) return false; + final PurchasesHistoryResult typedOther = other as PurchasesHistoryResult; + return typedOther.purchaseHistoryRecordList == purchaseHistoryRecordList && + typedOther.billingResult == billingResult; + } + + @override + int get hashCode => hashValues(billingResult, purchaseHistoryRecordList); + + /// The detailed description of the status of the [BillingClient.queryPurchaseHistory]. + final BillingResultWrapper billingResult; + + /// The list of queried purchase history records. + /// + /// May be empty, especially if [billingResult.responseCode] is not [BillingResponse.ok]. + @JsonKey(defaultValue: []) + final List purchaseHistoryRecordList; +} + +/// Possible state of a [PurchaseWrapper]. +/// +/// Wraps +/// [`BillingClient.api.Purchase.PurchaseState`](https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState.html). +/// * See also: [PurchaseWrapper]. +enum PurchaseStateWrapper { + /// The state is unspecified. + /// + /// No actions on the [PurchaseWrapper] should be performed on this state. + /// This is a catch-all. It should never be returned by the Play Billing Library. + @JsonValue(0) + unspecified_state, + + /// The user has completed the purchase process. + /// + /// The production should be delivered and then the purchase should be acknowledged. + /// * See also [BillingClient.acknowledgePurchase] for more details on acknowledging purchases. + @JsonValue(1) + purchased, + + /// The user has started the purchase process. + /// + /// The user should follow the instructions that were given to them by the Play + /// Billing Library to complete the purchase. + /// + /// You can also choose to remind the user to complete the purchase if you detected a + /// [PurchaseWrapper] is still in the `pending` state in the future while calling [BillingClient.queryPurchases]. + @JsonValue(2) + pending, +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart new file mode 100644 index 000000000000..5f0d936e09c2 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/purchase_wrapper.g.dart @@ -0,0 +1,109 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'purchase_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PurchaseWrapper _$PurchaseWrapperFromJson(Map json) { + return PurchaseWrapper( + orderId: json['orderId'] as String? ?? '', + packageName: json['packageName'] as String? ?? '', + purchaseTime: json['purchaseTime'] as int? ?? 0, + purchaseToken: json['purchaseToken'] as String? ?? '', + signature: json['signature'] as String? ?? '', + sku: json['sku'] as String? ?? '', + isAutoRenewing: json['isAutoRenewing'] as bool, + originalJson: json['originalJson'] as String? ?? '', + developerPayload: json['developerPayload'] as String?, + isAcknowledged: json['isAcknowledged'] as bool? ?? false, + purchaseState: + const PurchaseStateConverter().fromJson(json['purchaseState'] as int?), + ); +} + +Map _$PurchaseWrapperToJson(PurchaseWrapper instance) => + { + 'orderId': instance.orderId, + 'packageName': instance.packageName, + 'purchaseTime': instance.purchaseTime, + 'purchaseToken': instance.purchaseToken, + 'signature': instance.signature, + 'sku': instance.sku, + 'isAutoRenewing': instance.isAutoRenewing, + 'originalJson': instance.originalJson, + 'developerPayload': instance.developerPayload, + 'isAcknowledged': instance.isAcknowledged, + 'purchaseState': + const PurchaseStateConverter().toJson(instance.purchaseState), + }; + +PurchaseHistoryRecordWrapper _$PurchaseHistoryRecordWrapperFromJson(Map json) { + return PurchaseHistoryRecordWrapper( + purchaseTime: json['purchaseTime'] as int? ?? 0, + purchaseToken: json['purchaseToken'] as String? ?? '', + signature: json['signature'] as String? ?? '', + sku: json['sku'] as String? ?? '', + originalJson: json['originalJson'] as String? ?? '', + developerPayload: json['developerPayload'] as String?, + ); +} + +Map _$PurchaseHistoryRecordWrapperToJson( + PurchaseHistoryRecordWrapper instance) => + { + 'purchaseTime': instance.purchaseTime, + 'purchaseToken': instance.purchaseToken, + 'signature': instance.signature, + 'sku': instance.sku, + 'originalJson': instance.originalJson, + 'developerPayload': instance.developerPayload, + }; + +PurchasesResultWrapper _$PurchasesResultWrapperFromJson(Map json) { + return PurchasesResultWrapper( + responseCode: + const BillingResponseConverter().fromJson(json['responseCode'] as int?), + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + purchasesList: (json['purchasesList'] as List?) + ?.map((e) => + PurchaseWrapper.fromJson(Map.from(e as Map))) + .toList() ?? + [], + ); +} + +Map _$PurchasesResultWrapperToJson( + PurchasesResultWrapper instance) => + { + 'billingResult': instance.billingResult, + 'responseCode': + const BillingResponseConverter().toJson(instance.responseCode), + 'purchasesList': instance.purchasesList, + }; + +PurchasesHistoryResult _$PurchasesHistoryResultFromJson(Map json) { + return PurchasesHistoryResult( + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + purchaseHistoryRecordList: + (json['purchaseHistoryRecordList'] as List?) + ?.map((e) => PurchaseHistoryRecordWrapper.fromJson( + Map.from(e as Map))) + .toList() ?? + [], + ); +} + +Map _$PurchasesHistoryResultToJson( + PurchasesHistoryResult instance) => + { + 'billingResult': instance.billingResult, + 'purchaseHistoryRecordList': instance.purchaseHistoryRecordList, + }; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart new file mode 100644 index 000000000000..e3d13df2262a --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.dart @@ -0,0 +1,244 @@ +// Copyright 2013 The Flutter Authors. 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:json_annotation/json_annotation.dart'; +import 'billing_client_wrapper.dart'; +import 'enum_converters.dart'; + +// WARNING: Changes to `@JsonSerializable` classes need to be reflected in the +// below generated file. Run `flutter packages pub run build_runner watch` to +// rebuild and watch for further changes. +part 'sku_details_wrapper.g.dart'; + +/// The error message shown when the map represents billing result is invalid from method channel. +/// +/// This usually indicates a series underlining code issue in the plugin. +@visibleForTesting +const kInvalidBillingResultErrorMessage = + 'Invalid billing result map from method channel.'; + +/// Dart wrapper around [`com.android.billingclient.api.SkuDetails`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetails). +/// +/// Contains the details of an available product in Google Play Billing. +@JsonSerializable() +@SkuTypeConverter() +class SkuDetailsWrapper { + /// Creates a [SkuDetailsWrapper] with the given purchase details. + @visibleForTesting + SkuDetailsWrapper({ + required this.description, + required this.freeTrialPeriod, + required this.introductoryPrice, + required this.introductoryPriceMicros, + required this.introductoryPriceCycles, + required this.introductoryPricePeriod, + required this.price, + required this.priceAmountMicros, + required this.priceCurrencyCode, + required this.sku, + required this.subscriptionPeriod, + required this.title, + required this.type, + required this.originalPrice, + required this.originalPriceAmountMicros, + }); + + /// Constructs an instance of this 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. + @visibleForTesting + factory SkuDetailsWrapper.fromJson(Map map) => + _$SkuDetailsWrapperFromJson(map); + + /// Textual description of the product. + @JsonKey(defaultValue: '') + final String description; + + /// Trial period in ISO 8601 format. + @JsonKey(defaultValue: '') + final String freeTrialPeriod; + + /// Introductory price, only applies to [SkuType.subs]. Formatted ("$0.99"). + @JsonKey(defaultValue: '') + final String introductoryPrice; + + /// [introductoryPrice] in micro-units 990000 + @JsonKey(defaultValue: '') + final String introductoryPriceMicros; + + /// The number of subscription billing periods for which the user will be given the introductory price, such as 3. + /// Returns 0 if the SKU is not a subscription or doesn't have an introductory period. + @JsonKey(defaultValue: 0) + final int introductoryPriceCycles; + + /// The billing period of [introductoryPrice], in ISO 8601 format. + @JsonKey(defaultValue: '') + final String introductoryPricePeriod; + + /// Formatted with currency symbol ("$0.99"). + @JsonKey(defaultValue: '') + final String price; + + /// [price] in micro-units ("990000"). + @JsonKey(defaultValue: 0) + final int priceAmountMicros; + + /// [price] ISO 4217 currency code. + @JsonKey(defaultValue: '') + final String priceCurrencyCode; + + /// The product ID in Google Play Console. + @JsonKey(defaultValue: '') + final String sku; + + /// Applies to [SkuType.subs], formatted in ISO 8601. + @JsonKey(defaultValue: '') + final String subscriptionPeriod; + + /// The product's title. + @JsonKey(defaultValue: '') + final String title; + + /// The [SkuType] of the product. + final SkuType type; + + /// The original price that the user purchased this product for. + @JsonKey(defaultValue: '') + final String originalPrice; + + /// [originalPrice] in micro-units ("990000"). + @JsonKey(defaultValue: 0) + final int originalPriceAmountMicros; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + + final SkuDetailsWrapper typedOther = other; + return typedOther is SkuDetailsWrapper && + typedOther.description == description && + typedOther.freeTrialPeriod == freeTrialPeriod && + typedOther.introductoryPrice == introductoryPrice && + typedOther.introductoryPriceMicros == introductoryPriceMicros && + typedOther.introductoryPriceCycles == introductoryPriceCycles && + typedOther.introductoryPricePeriod == introductoryPricePeriod && + typedOther.price == price && + typedOther.priceAmountMicros == priceAmountMicros && + typedOther.sku == sku && + typedOther.subscriptionPeriod == subscriptionPeriod && + typedOther.title == title && + typedOther.type == type && + typedOther.originalPrice == originalPrice && + typedOther.originalPriceAmountMicros == originalPriceAmountMicros; + } + + @override + int get hashCode { + return hashValues( + description.hashCode, + freeTrialPeriod.hashCode, + introductoryPrice.hashCode, + introductoryPriceMicros.hashCode, + introductoryPriceCycles.hashCode, + introductoryPricePeriod.hashCode, + price.hashCode, + priceAmountMicros.hashCode, + sku.hashCode, + subscriptionPeriod.hashCode, + title.hashCode, + type.hashCode, + originalPrice, + originalPriceAmountMicros); + } +} + +/// Translation of [`com.android.billingclient.api.SkuDetailsResponseListener`](https://developer.android.com/reference/com/android/billingclient/api/SkuDetailsResponseListener.html). +/// +/// Returned by [BillingClient.querySkuDetails]. +@JsonSerializable() +class SkuDetailsResponseWrapper { + /// Creates a [SkuDetailsResponseWrapper] with the given purchase details. + @visibleForTesting + SkuDetailsResponseWrapper( + {required this.billingResult, required this.skuDetailsList}); + + /// Constructs an instance of this 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. + factory SkuDetailsResponseWrapper.fromJson(Map map) => + _$SkuDetailsResponseWrapperFromJson(map); + + /// The final result of the [BillingClient.querySkuDetails] call. + final BillingResultWrapper billingResult; + + /// A list of [SkuDetailsWrapper] matching the query to [BillingClient.querySkuDetails]. + @JsonKey(defaultValue: []) + final List skuDetailsList; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + + final SkuDetailsResponseWrapper typedOther = other; + return typedOther is SkuDetailsResponseWrapper && + typedOther.billingResult == billingResult && + typedOther.skuDetailsList == skuDetailsList; + } + + @override + int get hashCode => hashValues(billingResult, skuDetailsList); +} + +/// Params containing the response code and the debug message from the Play Billing API response. +@JsonSerializable() +@BillingResponseConverter() +class BillingResultWrapper { + /// Constructs the object with [responseCode] and [debugMessage]. + BillingResultWrapper({required this.responseCode, this.debugMessage}); + + /// Constructs an instance of this 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. + factory BillingResultWrapper.fromJson(Map? map) { + if (map == null || map.isEmpty) { + return BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage); + } + return _$BillingResultWrapperFromJson(map); + } + + /// Response code returned in the Play Billing API calls. + final BillingResponse responseCode; + + /// Debug message returned in the Play Billing API calls. + /// + /// Defaults to `null`. + /// This message uses an en-US locale and should not be shown to users. + final String? debugMessage; + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) { + return false; + } + + final BillingResultWrapper typedOther = other; + return typedOther is BillingResultWrapper && + typedOther.responseCode == responseCode && + typedOther.debugMessage == debugMessage; + } + + @override + int get hashCode => hashValues(responseCode, debugMessage); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart new file mode 100644 index 000000000000..a14affdf9ed3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sku_details_wrapper.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) { + return SkuDetailsWrapper( + description: json['description'] as String? ?? '', + freeTrialPeriod: json['freeTrialPeriod'] as String? ?? '', + introductoryPrice: json['introductoryPrice'] as String? ?? '', + introductoryPriceMicros: json['introductoryPriceMicros'] as String? ?? '', + introductoryPriceCycles: json['introductoryPriceCycles'] as int? ?? 0, + introductoryPricePeriod: json['introductoryPricePeriod'] as String? ?? '', + price: json['price'] as String? ?? '', + priceAmountMicros: json['priceAmountMicros'] as int? ?? 0, + priceCurrencyCode: json['priceCurrencyCode'] as String? ?? '', + sku: json['sku'] as String? ?? '', + subscriptionPeriod: json['subscriptionPeriod'] as String? ?? '', + title: json['title'] as String? ?? '', + type: const SkuTypeConverter().fromJson(json['type'] as String?), + originalPrice: json['originalPrice'] as String? ?? '', + originalPriceAmountMicros: json['originalPriceAmountMicros'] as int? ?? 0, + ); +} + +Map _$SkuDetailsWrapperToJson(SkuDetailsWrapper instance) => + { + 'description': instance.description, + 'freeTrialPeriod': instance.freeTrialPeriod, + 'introductoryPrice': instance.introductoryPrice, + 'introductoryPriceMicros': instance.introductoryPriceMicros, + 'introductoryPriceCycles': instance.introductoryPriceCycles, + 'introductoryPricePeriod': instance.introductoryPricePeriod, + 'price': instance.price, + 'priceAmountMicros': instance.priceAmountMicros, + 'priceCurrencyCode': instance.priceCurrencyCode, + 'sku': instance.sku, + 'subscriptionPeriod': instance.subscriptionPeriod, + 'title': instance.title, + 'type': const SkuTypeConverter().toJson(instance.type), + 'originalPrice': instance.originalPrice, + 'originalPriceAmountMicros': instance.originalPriceAmountMicros, + }; + +SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) { + return SkuDetailsResponseWrapper( + billingResult: + BillingResultWrapper.fromJson((json['billingResult'] as Map?)?.map( + (k, e) => MapEntry(k as String, e), + )), + skuDetailsList: (json['skuDetailsList'] as List?) + ?.map((e) => + SkuDetailsWrapper.fromJson(Map.from(e as Map))) + .toList() ?? + [], + ); +} + +Map _$SkuDetailsResponseWrapperToJson( + SkuDetailsResponseWrapper instance) => + { + 'billingResult': instance.billingResult, + 'skuDetailsList': instance.skuDetailsList, + }; + +BillingResultWrapper _$BillingResultWrapperFromJson(Map json) { + return BillingResultWrapper( + responseCode: + const BillingResponseConverter().fromJson(json['responseCode'] as int?), + debugMessage: json['debugMessage'] as String?, + ); +} + +Map _$BillingResultWrapperToJson( + BillingResultWrapper instance) => + { + 'responseCode': + const BillingResponseConverter().toJson(instance.responseCode), + 'debugMessage': instance.debugMessage, + }; diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/channel.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/channel.dart new file mode 100644 index 000000000000..f8ab4d48be7e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/channel.dart @@ -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 'package:flutter/services.dart'; + +/// Method channel for the plugin's platform<-->Dart calls. +const MethodChannel channel = + MethodChannel('plugins.flutter.io/in_app_purchase'); diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart new file mode 100644 index 000000000000..f71132a77ef3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform.dart @@ -0,0 +1,283 @@ +// Copyright 2013 The Flutter Authors. 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 'package:flutter/widgets.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../billing_client_wrappers.dart'; + +/// [IAPError.code] code for failed purchases. +const String kPurchaseErrorCode = 'purchase_error'; + +/// [IAPError.code] code used when a consuming a purchased item fails. +const String kConsumptionFailedErrorCode = 'consume_purchase_failed'; + +/// [IAPError.code] code used when a query for previous transaction has failed. +const String kRestoredPurchaseErrorCode = 'restore_transactions_failed'; + +/// Indicates store front is Google Play +const String kIAPSource = 'google_play'; + +/// An [InAppPurchasePlatform] that wraps Android BillingClient. +/// +/// This translates various `BillingClient` calls and responses into the +/// generic plugin API. +class InAppPurchaseAndroidPlatform extends InAppPurchasePlatform { + InAppPurchaseAndroidPlatform._() { + billingClient = BillingClient((PurchasesResultWrapper resultWrapper) async { + _purchaseUpdatedController + .add(await _getPurchaseDetailsFromResult(resultWrapper)); + }); + + // Register [InAppPurchaseAndroidPlatformAddition]. + InAppPurchasePlatformAddition.instance = + InAppPurchaseAndroidPlatformAddition(billingClient); + + _readyFuture = _connect(); + _purchaseUpdatedController = StreamController.broadcast(); + } + + /// Registers this class as the default instance of [InAppPurchasePlatform]. + static void registerPlatform() { + // Register the platform instance with the plugin platform + // interface. + InAppPurchasePlatform.instance = InAppPurchaseAndroidPlatform._(); + } + + static late StreamController> + _purchaseUpdatedController; + + @override + Stream> get purchaseStream => + _purchaseUpdatedController.stream; + + /// The [BillingClient] that's abstracted by [GooglePlayConnection]. + /// + /// This field should not be used out of test code. + @visibleForTesting + late final BillingClient billingClient; + + late Future _readyFuture; + static Set _productIdsToConsume = Set(); + + @override + Future isAvailable() async { + await _readyFuture; + return billingClient.isReady(); + } + + @override + Future queryProductDetails( + Set identifiers) async { + List responses; + PlatformException? exception; + try { + responses = await Future.wait([ + billingClient.querySkuDetails( + skuType: SkuType.inapp, skusList: identifiers.toList()), + billingClient.querySkuDetails( + skuType: SkuType.subs, skusList: identifiers.toList()) + ]); + } on PlatformException catch (e) { + exception = e; + responses = [ + // ignore: invalid_use_of_visible_for_testing_member + SkuDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []), + // ignore: invalid_use_of_visible_for_testing_member + SkuDetailsResponseWrapper( + billingResult: BillingResultWrapper( + responseCode: BillingResponse.error, debugMessage: e.code), + skuDetailsList: []) + ]; + } + List productDetailsList = + responses.expand((SkuDetailsResponseWrapper response) { + return response.skuDetailsList; + }).map((SkuDetailsWrapper skuDetailWrapper) { + return GooglePlayProductDetails.fromSkuDetails(skuDetailWrapper); + }).toList(); + + Set successIDS = productDetailsList + .map((ProductDetails productDetails) => productDetails.id) + .toSet(); + List notFoundIDS = identifiers.difference(successIDS).toList(); + return ProductDetailsResponse( + productDetails: productDetailsList, + notFoundIDs: notFoundIDS, + error: exception == null + ? null + : IAPError( + source: kIAPSource, + code: exception.code, + message: exception.message ?? '', + details: exception.details)); + } + + @override + Future buyNonConsumable({required PurchaseParam purchaseParam}) async { + ChangeSubscriptionParam? changeSubscriptionParam; + + if (purchaseParam is GooglePlayPurchaseParam) { + changeSubscriptionParam = purchaseParam.changeSubscriptionParam; + } + + BillingResultWrapper billingResultWrapper = + await billingClient.launchBillingFlow( + sku: purchaseParam.productDetails.id, + accountId: purchaseParam.applicationUserName, + oldSku: changeSubscriptionParam?.oldPurchaseDetails.productID, + purchaseToken: changeSubscriptionParam + ?.oldPurchaseDetails.verificationData.serverVerificationData, + prorationMode: changeSubscriptionParam?.prorationMode); + return billingResultWrapper.responseCode == BillingResponse.ok; + } + + @override + Future buyConsumable( + {required PurchaseParam purchaseParam, bool autoConsume = true}) { + if (autoConsume) { + _productIdsToConsume.add(purchaseParam.productDetails.id); + } + return buyNonConsumable(purchaseParam: purchaseParam); + } + + @override + Future completePurchase( + PurchaseDetails purchase) async { + assert( + purchase is GooglePlayPurchaseDetails, + 'On Android, the `purchase` should always be of type `GooglePlayPurchaseDetails`.', + ); + + GooglePlayPurchaseDetails googlePurchase = + purchase as GooglePlayPurchaseDetails; + + if (googlePurchase.billingClientPurchase.isAcknowledged) { + return BillingResultWrapper(responseCode: BillingResponse.ok); + } + + if (googlePurchase.verificationData == null) { + throw ArgumentError( + 'completePurchase unsuccessful. The `purchase.verificationData` is not valid'); + } + + return await billingClient + .acknowledgePurchase(purchase.verificationData.serverVerificationData); + } + + @override + Future restorePurchases({ + String? applicationUserName, + }) async { + List responses; + + responses = await Future.wait([ + billingClient.queryPurchases(SkuType.inapp), + billingClient.queryPurchases(SkuType.subs) + ]); + + Set errorCodeSet = responses + .where((PurchasesResultWrapper response) => + response.responseCode != BillingResponse.ok) + .map((PurchasesResultWrapper response) => + response.responseCode.toString()) + .toSet(); + + String errorMessage = + errorCodeSet.isNotEmpty ? errorCodeSet.join(', ') : ''; + + List pastPurchases = + responses.expand((PurchasesResultWrapper response) { + return response.purchasesList; + }).map((PurchaseWrapper purchaseWrapper) { + final GooglePlayPurchaseDetails purchaseDetails = + GooglePlayPurchaseDetails.fromPurchase(purchaseWrapper); + + purchaseDetails.status = PurchaseStatus.restored; + + return purchaseDetails; + }).toList(); + + if (errorMessage.isNotEmpty) { + throw InAppPurchaseException( + source: kIAPSource, + code: kRestoredPurchaseErrorCode, + message: errorMessage, + ); + } + + _purchaseUpdatedController.add(pastPurchases); + } + + Future _connect() => + billingClient.startConnection(onBillingServiceDisconnected: () {}); + + Future _maybeAutoConsumePurchase( + PurchaseDetails purchaseDetails) async { + if (!(purchaseDetails.status == PurchaseStatus.purchased && + _productIdsToConsume.contains(purchaseDetails.productID))) { + return purchaseDetails; + } + + final BillingResultWrapper billingResult = + await (InAppPurchasePlatformAddition.instance + as InAppPurchaseAndroidPlatformAddition) + .consumePurchase(purchaseDetails); + final BillingResponse consumedResponse = billingResult.responseCode; + if (consumedResponse != BillingResponse.ok) { + purchaseDetails.status = PurchaseStatus.error; + purchaseDetails.error = IAPError( + source: kIAPSource, + code: kConsumptionFailedErrorCode, + message: consumedResponse.toString(), + details: billingResult.debugMessage, + ); + } + _productIdsToConsume.remove(purchaseDetails.productID); + + return purchaseDetails; + } + + Future> _getPurchaseDetailsFromResult( + PurchasesResultWrapper resultWrapper) async { + IAPError? error; + if (resultWrapper.responseCode != BillingResponse.ok) { + error = IAPError( + source: kIAPSource, + code: kPurchaseErrorCode, + message: resultWrapper.responseCode.toString(), + details: resultWrapper.billingResult.debugMessage, + ); + } + final List> purchases = + resultWrapper.purchasesList.map((PurchaseWrapper purchase) { + return _maybeAutoConsumePurchase( + GooglePlayPurchaseDetails.fromPurchase(purchase)..error = error); + }).toList(); + if (purchases.isNotEmpty) { + return Future.wait(purchases); + } else { + return [ + PurchaseDetails( + purchaseID: '', + productID: '', + status: PurchaseStatus.error, + transactionDate: null, + verificationData: PurchaseVerificationData( + localVerificationData: '', + serverVerificationData: '', + source: kIAPSource)) + ..error = error + ]; + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart new file mode 100644 index 000000000000..e109c4e32ade --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/in_app_purchase_android_platform_addition.dart @@ -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 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../billing_client_wrappers.dart'; + +/// Contains InApp Purchase features that are only available on PlayStore. +class InAppPurchaseAndroidPlatformAddition + extends InAppPurchasePlatformAddition { + /// Creates a [InAppPurchaseAndroidPlatformAddition] which uses the supplied + /// `BillingClient` to provide Android specific features. + InAppPurchaseAndroidPlatformAddition(this._billingClient) { + assert( + _enablePendingPurchase, + 'enablePendingPurchases() must be called when initializing the application and before you access the [InAppPurchase.instance].', + ); + + _billingClient.enablePendingPurchases(); + } + + /// Whether pending purchase is enabled. + /// + /// See also [enablePendingPurchases] for more on pending purchases. + static bool get enablePendingPurchase => _enablePendingPurchase; + static bool _enablePendingPurchase = false; + + /// Enable the [InAppPurchaseConnection] to handle pending purchases. + /// + /// This method is required to be called when initialize the application. + /// It is to acknowledge your application has been updated to support pending purchases. + /// See [Support pending transactions](https://developer.android.com/google/play/billing/billing_library_overview#pending) + /// for more details. + /// Failure to call this method before access [instance] will throw an exception. + static void enablePendingPurchases() { + _enablePendingPurchase = true; + } + + final BillingClient _billingClient; + + /// Mark that the user has consumed a product. + /// + /// You are responsible for consuming all consumable purchases once they are + /// delivered. The user won't be able to buy the same product again until the + /// purchase of the product is consumed. + Future consumePurchase(PurchaseDetails purchase) { + if (purchase.verificationData == null) { + throw ArgumentError( + 'consumePurchase unsuccessful. The `purchase.verificationData` is not valid'); + } + return _billingClient + .consumeAsync(purchase.verificationData.serverVerificationData); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.dart new file mode 100644 index 000000000000..1099da3bf159 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/change_subscription_param.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 '../../billing_client_wrappers.dart'; +import 'types.dart'; + +/// This parameter object for upgrading or downgrading an existing subscription. +class ChangeSubscriptionParam { + /// Creates a new change subscription param object with given data + ChangeSubscriptionParam({ + required this.oldPurchaseDetails, + this.prorationMode, + }); + + /// The purchase object of the existing subscription that the user needs to + /// upgrade/downgrade from. + final GooglePlayPurchaseDetails oldPurchaseDetails; + + /// The proration mode. + /// + /// This is an optional parameter that indicates how to handle the existing + /// subscription when the new subscription comes into effect. + final ProrationMode? prorationMode; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.dart new file mode 100644 index 000000000000..62589038804e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_product_details.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 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +/// The class represents the information of a product as registered in at +/// Google Play store front. +class GooglePlayProductDetails extends ProductDetails { + /// Creates a new Google Play specific product details object with the + /// provided details. + GooglePlayProductDetails({ + required String id, + required String title, + required String description, + required String price, + required double rawPrice, + required String currencyCode, + required this.skuDetails, + }) : super( + id: id, + title: title, + description: description, + price: price, + rawPrice: rawPrice, + currencyCode: currencyCode, + ); + + /// Points back to the [SkuDetailsWrapper] object that was used to generate + /// this [GooglePlayProductDetails] object. + final SkuDetailsWrapper skuDetails; + + /// Generate a [GooglePlayProductDetails] object based on an Android + /// [SkuDetailsWrapper] object. + factory GooglePlayProductDetails.fromSkuDetails( + SkuDetailsWrapper skuDetails, + ) { + return GooglePlayProductDetails( + id: skuDetails.sku, + title: skuDetails.title, + description: skuDetails.description, + price: skuDetails.price, + rawPrice: ((skuDetails.priceAmountMicros) / 1000000.0).toDouble(), + currencyCode: skuDetails.priceCurrencyCode, + skuDetails: skuDetails, + ); + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart new file mode 100644 index 000000000000..66e3a8f5a590 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_details.dart @@ -0,0 +1,71 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../billing_client_wrappers.dart'; +import '../in_app_purchase_android_platform.dart'; + +/// The class represents the information of a purchase made using Google Play. +class GooglePlayPurchaseDetails extends PurchaseDetails { + /// Creates a new Google Play specific purchase details object with the + /// provided details. + GooglePlayPurchaseDetails({ + String? purchaseID, + required String productID, + required PurchaseVerificationData verificationData, + required String? transactionDate, + required this.billingClientPurchase, + required PurchaseStatus status, + }) : super( + productID: productID, + purchaseID: purchaseID, + transactionDate: transactionDate, + verificationData: verificationData, + status: status) { + this.status = status; + } + + /// Points back to the [PurchaseWrapper] which was used to generate this + /// [GooglePlayPurchaseDetails] object. + final PurchaseWrapper billingClientPurchase; + + late PurchaseStatus _status; + + /// The status that this [PurchaseDetails] is currently on. + PurchaseStatus get status => _status; + set status(PurchaseStatus status) { + _pendingCompletePurchase = status == PurchaseStatus.purchased; + _status = status; + } + + bool _pendingCompletePurchase = false; + bool get pendingCompletePurchase => _pendingCompletePurchase; + + /// Generate a [PurchaseDetails] object based on an Android [Purchase] object. + factory GooglePlayPurchaseDetails.fromPurchase(PurchaseWrapper purchase) { + final GooglePlayPurchaseDetails purchaseDetails = GooglePlayPurchaseDetails( + purchaseID: purchase.orderId, + productID: purchase.sku, + verificationData: PurchaseVerificationData( + localVerificationData: purchase.originalJson, + serverVerificationData: purchase.purchaseToken, + source: kIAPSource), + transactionDate: purchase.purchaseTime.toString(), + billingClientPurchase: purchase, + status: PurchaseStateConverter().toPurchaseStatus(purchase.purchaseState), + ); + + if (purchaseDetails.status == PurchaseStatus.error) { + purchaseDetails.error = IAPError( + source: kIAPSource, + code: kPurchaseErrorCode, + message: '', + ); + } + + return purchaseDetails; + } +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_param.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_param.dart new file mode 100644 index 000000000000..bcf0ad62a245 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/google_play_purchase_param.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. + +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import '../../in_app_purchase_android.dart'; + +/// Google Play specific parameter object for generating a purchase. +class GooglePlayPurchaseParam extends PurchaseParam { + /// Creates a new [GooglePlayPurchaseParam] object with the given data. + GooglePlayPurchaseParam({ + required ProductDetails productDetails, + String? applicationUserName, + this.changeSubscriptionParam, + }) : super( + productDetails: productDetails, + applicationUserName: applicationUserName, + ); + + /// The 'changeSubscriptionParam' containing information for upgrading or + /// downgrading an existing subscription. + final ChangeSubscriptionParam? changeSubscriptionParam; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.dart new file mode 100644 index 000000000000..2982363c68ad --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/lib/src/types/types.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 'change_subscription_param.dart'; +export 'google_play_product_details.dart'; +export 'google_play_purchase_details.dart'; +export 'google_play_purchase_param.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml new file mode 100644 index 000000000000..41cb3e87e185 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/pubspec.yaml @@ -0,0 +1,35 @@ +name: in_app_purchase_android +description: An implementation for the Android platform of the Flutter `in_app_purchase` plugin. This uses the Android BillingClient APIs. +repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_android +version: 0.1.0 + +# TODO(mvanbeusekom): Remove when in_app_purchase_platform_interface is published +publish_to: 'none' + +flutter: + plugin: + platforms: + android: + package: io.flutter.plugins.inapppurchase + pluginClass: InAppPurchasePlugin + +dependencies: + # TODO(mvanbeusekom): Replace with pub.dev version when in_app_purchase_platform_interface is published + in_app_purchase_platform_interface: + path: ../in_app_purchase_platform_interface + + flutter: + sdk: flutter + + meta: ^1.3.0 + test: ^1.16.0 + +dev_dependencies: + build_runner: ^1.11.1 + json_serializable: ^4.1.1 + flutter_test: + sdk: flutter + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=1.20.0" \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart new file mode 100644 index 000000000000..ec7289735ade --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/billing_client_wrapper_test.dart @@ -0,0 +1,547 @@ +// Copyright 2013 The Flutter Authors. 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:flutter/services.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:in_app_purchase_android/src/channel.dart'; + +import '../stub_in_app_purchase_platform.dart'; +import 'sku_details_wrapper_test.dart'; +import 'purchase_wrapper_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + late BillingClient billingClient; + + setUpAll(() => + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler)); + + setUp(() { + billingClient = BillingClient((PurchasesResultWrapper _) {}); + billingClient.enablePendingPurchases(); + stubPlatform.reset(); + }); + + group('isReady', () { + test('true', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: true); + expect(await billingClient.isReady(), isTrue); + }); + + test('false', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: false); + expect(await billingClient.isReady(), isFalse); + }); + }); + + // Make sure that the enum values are supported and that the converter call + // does not fail + test('response states', () async { + BillingResponseConverter converter = BillingResponseConverter(); + converter.fromJson(-3); + converter.fromJson(-2); + converter.fromJson(-1); + converter.fromJson(0); + converter.fromJson(1); + converter.fromJson(2); + converter.fromJson(3); + converter.fromJson(4); + converter.fromJson(5); + converter.fromJson(6); + converter.fromJson(7); + converter.fromJson(8); + }); + + group('startConnection', () { + final String methodName = + 'BillingClient#startConnection(BillingClientStateListener)'; + test('returns BillingResultWrapper', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + stubPlatform.addResponse( + name: methodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect( + await billingClient.startConnection( + onBillingServiceDisconnected: () {}), + equals(billingResult)); + }); + + test('passes handle to onBillingServiceDisconnected', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + stubPlatform.addResponse( + name: methodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + ); + await billingClient.startConnection(onBillingServiceDisconnected: () {}); + final MethodCall call = stubPlatform.previousCallMatching(methodName); + expect( + call.arguments, + equals( + {'handle': 0, 'enablePendingPurchases': true})); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: methodName, + value: null, + ); + + expect( + await billingClient.startConnection( + onBillingServiceDisconnected: () {}), + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); + + test('endConnection', () async { + final String endConnectionName = 'BillingClient#endConnection()'; + expect(stubPlatform.countPreviousCalls(endConnectionName), equals(0)); + stubPlatform.addResponse(name: endConnectionName, value: null); + await billingClient.endConnection(); + expect(stubPlatform.countPreviousCalls(endConnectionName), equals(1)); + }); + + group('querySkuDetails', () { + final String queryMethodName = + 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; + + test('handles empty skuDetails', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[] + }); + + final SkuDetailsResponseWrapper response = await billingClient + .querySkuDetails( + skuType: SkuType.inapp, skusList: ['invalid']); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect(response.billingResult, equals(billingResult)); + expect(response.skuDetailsList, isEmpty); + }); + + test('returns SkuDetailsResponseWrapper', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + }); + + final SkuDetailsResponseWrapper response = await billingClient + .querySkuDetails( + skuType: SkuType.inapp, skusList: ['invalid']); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + expect(response.billingResult, equals(billingResult)); + expect(response.skuDetailsList, contains(dummySkuDetails)); + }); + + test('handles null method channel response', () async { + stubPlatform.addResponse(name: queryMethodName, value: null); + + final SkuDetailsResponseWrapper response = await billingClient + .querySkuDetails( + skuType: SkuType.inapp, skusList: ['invalid']); + + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage); + expect(response.billingResult, equals(billingResult)); + expect(response.skuDetailsList, isEmpty); + }); + }); + + group('launchBillingFlow', () { + final String launchMethodName = + 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)'; + + test('serializes and deserializes data', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + final String profileId = "hashedProfileId"; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['obfuscatedProfileId'], equals(profileId)); + }); + + test( + 'Change subscription throws assertion error `oldSku` and `purchaseToken` has different nullability', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = 'hashedAccountId'; + final String profileId = 'hashedProfileId'; + + expect( + billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: dummyOldPurchase.sku, + purchaseToken: null), + throwsAssertionError); + + expect( + billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: null, + purchaseToken: dummyOldPurchase.purchaseToken), + throwsAssertionError); + }); + + test( + 'serializes and deserializes data on change subscription without proration', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = 'hashedAccountId'; + final String profileId = 'hashedProfileId'; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: dummyOldPurchase.sku, + purchaseToken: dummyOldPurchase.purchaseToken), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect( + arguments['purchaseToken'], equals(dummyOldPurchase.purchaseToken)); + expect(arguments['obfuscatedProfileId'], equals(profileId)); + }); + + test( + 'serializes and deserializes data on change subscription with proration', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = 'hashedAccountId'; + final String profileId = 'hashedProfileId'; + final prorationMode = ProrationMode.immediateAndChargeProratedPrice; + + expect( + await billingClient.launchBillingFlow( + sku: skuDetails.sku, + accountId: accountId, + obfuscatedProfileId: profileId, + oldSku: dummyOldPurchase.sku, + prorationMode: prorationMode, + purchaseToken: dummyOldPurchase.purchaseToken), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], equals(accountId)); + expect(arguments['oldSku'], equals(dummyOldPurchase.sku)); + expect(arguments['obfuscatedProfileId'], equals(profileId)); + expect( + arguments['purchaseToken'], equals(dummyOldPurchase.purchaseToken)); + expect(arguments['prorationMode'], + ProrationModeConverter().toJson(prorationMode)); + }); + + test('handles null accountId', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + + expect(await billingClient.launchBillingFlow(sku: skuDetails.sku), + equals(expectedBillingResult)); + Map arguments = + stubPlatform.previousCallMatching(launchMethodName).arguments; + expect(arguments['sku'], equals(skuDetails.sku)); + expect(arguments['accountId'], isNull); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: launchMethodName, + value: null, + ); + final SkuDetailsWrapper skuDetails = dummySkuDetails; + expect( + await billingClient.launchBillingFlow(sku: skuDetails.sku), + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); + + group('queryPurchases', () { + const String queryPurchasesMethodName = + 'BillingClient#queryPurchases(String)'; + + test('serializes and deserializes data', () async { + final BillingResponse expectedCode = BillingResponse.ok; + final List expectedList = [ + dummyPurchase + ]; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform + .addResponse(name: queryPurchasesMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(expectedCode), + 'purchasesList': expectedList + .map((PurchaseWrapper purchase) => buildPurchaseMap(purchase)) + .toList(), + }); + + final PurchasesResultWrapper response = + await billingClient.queryPurchases(SkuType.inapp); + + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.responseCode, equals(expectedCode)); + expect(response.purchasesList, equals(expectedList)); + }); + + test('handles empty purchases', () async { + final BillingResponse expectedCode = BillingResponse.userCanceled; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform + .addResponse(name: queryPurchasesMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(expectedCode), + 'purchasesList': [], + }); + + final PurchasesResultWrapper response = + await billingClient.queryPurchases(SkuType.inapp); + + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.responseCode, equals(expectedCode)); + expect(response.purchasesList, isEmpty); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: queryPurchasesMethodName, + value: null, + ); + final PurchasesResultWrapper response = + await billingClient.queryPurchases(SkuType.inapp); + + expect( + response.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(response.responseCode, BillingResponse.error); + expect(response.purchasesList, isEmpty); + }); + }); + + group('queryPurchaseHistory', () { + const String queryPurchaseHistoryMethodName = + 'BillingClient#queryPurchaseHistoryAsync(String, PurchaseHistoryResponseListener)'; + + test('serializes and deserializes data', () async { + final BillingResponse expectedCode = BillingResponse.ok; + final List expectedList = + [ + dummyPurchaseHistoryRecord, + ]; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: queryPurchaseHistoryMethodName, + value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchaseHistoryRecordList': expectedList + .map((PurchaseHistoryRecordWrapper purchaseHistoryRecord) => + buildPurchaseHistoryRecordMap(purchaseHistoryRecord)) + .toList(), + }); + + final PurchasesHistoryResult response = + await billingClient.queryPurchaseHistory(SkuType.inapp); + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.purchaseHistoryRecordList, equals(expectedList)); + }); + + test('handles empty purchases', () async { + final BillingResponse expectedCode = BillingResponse.userCanceled; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryPurchaseHistoryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchaseHistoryRecordList': [], + }); + + final PurchasesHistoryResult response = + await billingClient.queryPurchaseHistory(SkuType.inapp); + + expect(response.billingResult, equals(expectedBillingResult)); + expect(response.purchaseHistoryRecordList, isEmpty); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: queryPurchaseHistoryMethodName, + value: null, + ); + final PurchasesHistoryResult response = + await billingClient.queryPurchaseHistory(SkuType.inapp); + + expect( + response.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(response.purchaseHistoryRecordList, isEmpty); + }); + }); + + group('consume purchases', () { + const String consumeMethodName = + 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; + test('consume purchase async success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResult)); + + final BillingResultWrapper billingResult = + await billingClient.consumeAsync('dummy token'); + + expect(billingResult, equals(expectedBillingResult)); + }); + + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: consumeMethodName, + value: null, + ); + final BillingResultWrapper billingResult = + await billingClient.consumeAsync('dummy token'); + + expect( + billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); + + group('acknowledge purchases', () { + const String acknowledgeMethodName = + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; + test('acknowledge purchase success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: acknowledgeMethodName, + value: buildBillingResultMap(expectedBillingResult)); + + final BillingResultWrapper billingResult = + await billingClient.acknowledgePurchase('dummy token'); + + expect(billingResult, equals(expectedBillingResult)); + }); + test('handles method channel returning null', () async { + stubPlatform.addResponse( + name: acknowledgeMethodName, + value: null, + ); + final BillingResultWrapper billingResult = + await billingClient.acknowledgePurchase('dummy token'); + + expect( + billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart new file mode 100644 index 000000000000..a3e80a89fa7e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/purchase_wrapper_test.dart @@ -0,0 +1,214 @@ +// Copyright 2013 The Flutter Authors. 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_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:test/test.dart'; + +final PurchaseWrapper dummyPurchase = PurchaseWrapper( + orderId: 'orderId', + packageName: 'packageName', + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'dummy payload', + isAcknowledged: true, + purchaseState: PurchaseStateWrapper.purchased, +); + +final PurchaseWrapper dummyUnacknowledgedPurchase = PurchaseWrapper( + orderId: 'orderId', + packageName: 'packageName', + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'dummy payload', + isAcknowledged: false, + purchaseState: PurchaseStateWrapper.purchased, +); + +final PurchaseHistoryRecordWrapper dummyPurchaseHistoryRecord = + PurchaseHistoryRecordWrapper( + purchaseTime: 0, + signature: 'signature', + sku: 'sku', + purchaseToken: 'purchaseToken', + originalJson: '', + developerPayload: 'dummy payload', +); + +final PurchaseWrapper dummyOldPurchase = PurchaseWrapper( + orderId: 'oldOrderId', + packageName: 'oldPackageName', + purchaseTime: 0, + signature: 'oldSignature', + sku: 'oldSku', + purchaseToken: 'oldPurchaseToken', + isAutoRenewing: false, + originalJson: '', + developerPayload: 'old dummy payload', + isAcknowledged: true, + purchaseState: PurchaseStateWrapper.purchased, +); + +void main() { + group('PurchaseWrapper', () { + test('converts from map', () { + final PurchaseWrapper expected = dummyPurchase; + final PurchaseWrapper parsed = + PurchaseWrapper.fromJson(buildPurchaseMap(expected)); + + expect(parsed, equals(expected)); + }); + + test('toPurchaseDetails() should return correct PurchaseDetail object', () { + final GooglePlayPurchaseDetails details = + GooglePlayPurchaseDetails.fromPurchase(dummyPurchase); + expect(details.purchaseID, dummyPurchase.orderId); + expect(details.productID, dummyPurchase.sku); + expect(details.transactionDate, dummyPurchase.purchaseTime.toString()); + expect(details.verificationData, isNotNull); + expect(details.verificationData.source, kIAPSource); + expect(details.verificationData.localVerificationData, + dummyPurchase.originalJson); + expect(details.verificationData.serverVerificationData, + dummyPurchase.purchaseToken); + expect(details.billingClientPurchase, dummyPurchase); + expect(details.pendingCompletePurchase, true); + }); + }); + + group('PurchaseHistoryRecordWrapper', () { + test('converts from map', () { + final PurchaseHistoryRecordWrapper expected = dummyPurchaseHistoryRecord; + final PurchaseHistoryRecordWrapper parsed = + PurchaseHistoryRecordWrapper.fromJson( + buildPurchaseHistoryRecordMap(expected)); + + expect(parsed, equals(expected)); + }); + }); + + group('PurchasesResultWrapper', () { + test('parsed from map', () { + final BillingResponse responseCode = BillingResponse.ok; + final List purchases = [ + dummyPurchase, + dummyPurchase + ]; + const String debugMessage = 'dummy Message'; + final BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final PurchasesResultWrapper expected = PurchasesResultWrapper( + billingResult: billingResult, + responseCode: responseCode, + purchasesList: purchases); + final PurchasesResultWrapper parsed = + PurchasesResultWrapper.fromJson({ + 'billingResult': buildBillingResultMap(billingResult), + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'purchasesList': >[ + buildPurchaseMap(dummyPurchase), + buildPurchaseMap(dummyPurchase) + ] + }); + expect(parsed.billingResult, equals(expected.billingResult)); + expect(parsed.responseCode, equals(expected.responseCode)); + expect(parsed.purchasesList, containsAll(expected.purchasesList)); + }); + + test('parsed from empty map', () { + final PurchasesResultWrapper parsed = + PurchasesResultWrapper.fromJson({}); + expect( + parsed.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(parsed.responseCode, BillingResponse.error); + expect(parsed.purchasesList, isEmpty); + }); + }); + + group('PurchasesHistoryResult', () { + test('parsed from map', () { + final BillingResponse responseCode = BillingResponse.ok; + final List purchaseHistoryRecordList = + [ + dummyPurchaseHistoryRecord, + dummyPurchaseHistoryRecord + ]; + const String debugMessage = 'dummy Message'; + final BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final PurchasesHistoryResult expected = PurchasesHistoryResult( + billingResult: billingResult, + purchaseHistoryRecordList: purchaseHistoryRecordList); + final PurchasesHistoryResult parsed = + PurchasesHistoryResult.fromJson({ + 'billingResult': buildBillingResultMap(billingResult), + 'purchaseHistoryRecordList': >[ + buildPurchaseHistoryRecordMap(dummyPurchaseHistoryRecord), + buildPurchaseHistoryRecordMap(dummyPurchaseHistoryRecord) + ] + }); + expect(parsed.billingResult, equals(billingResult)); + expect(parsed.purchaseHistoryRecordList, + containsAll(expected.purchaseHistoryRecordList)); + }); + + test('parsed from empty map', () { + final PurchasesHistoryResult parsed = + PurchasesHistoryResult.fromJson({}); + expect( + parsed.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(parsed.purchaseHistoryRecordList, isEmpty); + }); + }); +} + +Map buildPurchaseMap(PurchaseWrapper original) { + return { + 'orderId': original.orderId, + 'packageName': original.packageName, + 'purchaseTime': original.purchaseTime, + 'signature': original.signature, + 'sku': original.sku, + 'purchaseToken': original.purchaseToken, + 'isAutoRenewing': original.isAutoRenewing, + 'originalJson': original.originalJson, + 'developerPayload': original.developerPayload, + 'purchaseState': PurchaseStateConverter().toJson(original.purchaseState), + 'isAcknowledged': original.isAcknowledged, + }; +} + +Map buildPurchaseHistoryRecordMap( + PurchaseHistoryRecordWrapper original) { + return { + 'purchaseTime': original.purchaseTime, + 'signature': original.signature, + 'sku': original.sku, + 'purchaseToken': original.purchaseToken, + 'originalJson': original.originalJson, + 'developerPayload': original.developerPayload, + }; +} + +Map buildBillingResultMap(BillingResultWrapper original) { + return { + 'responseCode': BillingResponseConverter().toJson(original.responseCode), + 'debugMessage': original.debugMessage, + }; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart new file mode 100644 index 000000000000..ead6d26576f3 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/billing_client_wrappers/sku_details_wrapper_test.dart @@ -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. + +import 'package:in_app_purchase_android/src/types/google_play_product_details.dart'; +import 'package:test/test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; + +final SkuDetailsWrapper dummySkuDetails = SkuDetailsWrapper( + description: 'description', + freeTrialPeriod: 'freeTrialPeriod', + introductoryPrice: 'introductoryPrice', + introductoryPriceMicros: 'introductoryPriceMicros', + introductoryPriceCycles: 1, + introductoryPricePeriod: 'introductoryPricePeriod', + price: 'price', + priceAmountMicros: 1000, + priceCurrencyCode: 'priceCurrencyCode', + sku: 'sku', + subscriptionPeriod: 'subscriptionPeriod', + title: 'title', + type: SkuType.inapp, + originalPrice: 'originalPrice', + originalPriceAmountMicros: 1000, +); + +void main() { + group('SkuDetailsWrapper', () { + test('converts from map', () { + final SkuDetailsWrapper expected = dummySkuDetails; + final SkuDetailsWrapper parsed = + SkuDetailsWrapper.fromJson(buildSkuMap(expected)); + + expect(parsed, equals(expected)); + }); + }); + + group('SkuDetailsResponseWrapper', () { + test('parsed from map', () { + final BillingResponse responseCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final List skusDetails = [ + dummySkuDetails, + dummySkuDetails + ]; + BillingResultWrapper result = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( + billingResult: result, skuDetailsList: skusDetails); + + final SkuDetailsResponseWrapper parsed = + SkuDetailsResponseWrapper.fromJson({ + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[ + buildSkuMap(dummySkuDetails), + buildSkuMap(dummySkuDetails) + ] + }); + + expect(parsed.billingResult, equals(expected.billingResult)); + expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); + }); + + test('toProductDetails() should return correct Product object', () { + final SkuDetailsWrapper wrapper = + SkuDetailsWrapper.fromJson(buildSkuMap(dummySkuDetails)); + final GooglePlayProductDetails product = + GooglePlayProductDetails.fromSkuDetails(wrapper); + expect(product.title, wrapper.title); + expect(product.description, wrapper.description); + expect(product.id, wrapper.sku); + expect(product.price, wrapper.price); + expect(product.skuDetails, wrapper); + }); + + test('handles empty list of skuDetails', () { + final BillingResponse responseCode = BillingResponse.error; + const String debugMessage = 'dummy message'; + final List skusDetails = []; + BillingResultWrapper billingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + final SkuDetailsResponseWrapper expected = SkuDetailsResponseWrapper( + billingResult: billingResult, skuDetailsList: skusDetails); + + final SkuDetailsResponseWrapper parsed = + SkuDetailsResponseWrapper.fromJson({ + 'billingResult': { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'debugMessage': debugMessage, + }, + 'skuDetailsList': >[] + }); + + expect(parsed.billingResult, equals(expected.billingResult)); + expect(parsed.skuDetailsList, containsAll(expected.skuDetailsList)); + }); + + test('fromJson creates an object with default values', () { + final SkuDetailsResponseWrapper skuDetails = + SkuDetailsResponseWrapper.fromJson({}); + expect( + skuDetails.billingResult, + equals(BillingResultWrapper( + responseCode: BillingResponse.error, + debugMessage: kInvalidBillingResultErrorMessage))); + expect(skuDetails.skuDetailsList, isEmpty); + }); + }); + + group('BillingResultWrapper', () { + test('fromJson on empty map creates an object with default values', () { + final BillingResultWrapper billingResult = + BillingResultWrapper.fromJson({}); + expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); + expect(billingResult.responseCode, BillingResponse.error); + }); + + test('fromJson on null creates an object with default values', () { + final BillingResultWrapper billingResult = + BillingResultWrapper.fromJson(null); + expect(billingResult.debugMessage, kInvalidBillingResultErrorMessage); + expect(billingResult.responseCode, BillingResponse.error); + }); + }); +} + +Map buildSkuMap(SkuDetailsWrapper original) { + return { + 'description': original.description, + 'freeTrialPeriod': original.freeTrialPeriod, + 'introductoryPrice': original.introductoryPrice, + 'introductoryPriceMicros': original.introductoryPriceMicros, + 'introductoryPriceCycles': original.introductoryPriceCycles, + 'introductoryPricePeriod': original.introductoryPricePeriod, + 'price': original.price, + 'priceAmountMicros': original.priceAmountMicros, + 'priceCurrencyCode': original.priceCurrencyCode, + 'sku': original.sku, + 'subscriptionPeriod': original.subscriptionPeriod, + 'title': original.title, + 'type': original.type.toString().substring(8), + 'originalPrice': original.originalPrice, + 'originalPriceAmountMicros': original.originalPriceAmountMicros, + }; +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart new file mode 100644 index 000000000000..90b7154257f7 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_addition_test.dart @@ -0,0 +1,64 @@ +// Copyright 2013 The Flutter Authors. 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' as widgets; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/channel.dart'; +import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; + +import 'billing_client_wrappers/purchase_wrapper_test.dart'; +import 'stub_in_app_purchase_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + late InAppPurchaseAndroidPlatformAddition iapAndroidPlatformAddition; + const String startConnectionCall = + 'BillingClient#startConnection(BillingClientStateListener)'; + const String endConnectionCall = 'BillingClient#endConnection()'; + + setUpAll(() { + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler); + }); + + setUp(() { + widgets.WidgetsFlutterBinding.ensureInitialized(); + + InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); + + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: startConnectionCall, + value: buildBillingResultMap(expectedBillingResult)); + stubPlatform.addResponse(name: endConnectionCall, value: null); + iapAndroidPlatformAddition = + InAppPurchaseAndroidPlatformAddition(BillingClient((_) {})); + }); + + group('consume purchases', () { + const String consumeMethodName = + 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; + test('consume purchase async success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + final BillingResultWrapper billingResultWrapper = + await iapAndroidPlatformAddition.consumePurchase( + GooglePlayPurchaseDetails.fromPurchase(dummyPurchase)); + + expect(billingResultWrapper, equals(expectedBillingResult)); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart new file mode 100644 index 000000000000..01c73d6ed43e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/in_app_purchase_android_platform_test.dart @@ -0,0 +1,646 @@ +// Copyright 2013 The Flutter Authors. 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 'package:flutter/widgets.dart' as widgets; +import 'package:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_android/billing_client_wrappers.dart'; +import 'package:in_app_purchase_android/in_app_purchase_android.dart'; +import 'package:in_app_purchase_android/src/billing_client_wrappers/enum_converters.dart'; +import 'package:in_app_purchase_android/src/channel.dart'; +import 'package:in_app_purchase_android/src/in_app_purchase_android_platform_addition.dart'; +import 'package:in_app_purchase_platform_interface/in_app_purchase_platform_interface.dart'; + +import 'billing_client_wrappers/purchase_wrapper_test.dart'; +import 'billing_client_wrappers/sku_details_wrapper_test.dart'; +import 'stub_in_app_purchase_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + late InAppPurchaseAndroidPlatform iapAndroidPlatform; + const String startConnectionCall = + 'BillingClient#startConnection(BillingClientStateListener)'; + const String endConnectionCall = 'BillingClient#endConnection()'; + + setUpAll(() { + channel.setMockMethodCallHandler(stubPlatform.fakeMethodCallHandler); + }); + + setUp(() { + widgets.WidgetsFlutterBinding.ensureInitialized(); + + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: startConnectionCall, + value: buildBillingResultMap(expectedBillingResult)); + stubPlatform.addResponse(name: endConnectionCall, value: null); + + InAppPurchaseAndroidPlatformAddition.enablePendingPurchases(); + InAppPurchaseAndroidPlatform.registerPlatform(); + iapAndroidPlatform = + InAppPurchasePlatform.instance as InAppPurchaseAndroidPlatform; + }); + + tearDown(() { + stubPlatform.reset(); + }); + + group('connection management', () { + test('connects on initialization', () { + //await iapAndroidPlatform.isAvailable(); + expect(stubPlatform.countPreviousCalls(startConnectionCall), equals(1)); + }); + }); + + group('isAvailable', () { + test('true', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: true); + expect(await iapAndroidPlatform.isAvailable(), isTrue); + }); + + test('false', () async { + stubPlatform.addResponse(name: 'BillingClient#isReady()', value: false); + expect(await iapAndroidPlatform.isAvailable(), isFalse); + }); + }); + + group('querySkuDetails', () { + final String queryMethodName = + 'BillingClient#querySkuDetailsAsync(SkuDetailsParams, SkuDetailsResponseListener)'; + + test('handles empty skuDetails', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'skuDetailsList': [], + }); + + final ProductDetailsResponse response = + await iapAndroidPlatform.queryProductDetails([''].toSet()); + expect(response.productDetails, isEmpty); + }); + + test('should get correct product details', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + }); + // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // of 1. + final ProductDetailsResponse response = await iapAndroidPlatform + .queryProductDetails(['valid'].toSet()); + expect(response.productDetails.first.title, dummySkuDetails.title); + expect(response.productDetails.first.description, + dummySkuDetails.description); + expect(response.productDetails.first.price, dummySkuDetails.price); + }); + + test('should get the correct notFoundIDs', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'skuDetailsList': >[buildSkuMap(dummySkuDetails)] + }); + // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // of 1. + final ProductDetailsResponse response = await iapAndroidPlatform + .queryProductDetails(['invalid'].toSet()); + expect(response.notFoundIDs.first, 'invalid'); + }); + + test( + 'should have error stored in the response when platform exception is thrown', + () async { + final BillingResponse responseCode = BillingResponse.ok; + stubPlatform.addResponse( + name: queryMethodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'skuDetailsList': >[ + buildSkuMap(dummySkuDetails) + ] + }, + additionalStepBeforeReturn: (_) { + throw PlatformException( + code: 'error_code', + message: 'error_message', + details: {'info': 'error_info'}, + ); + }); + // Since queryProductDetails makes 2 platform method calls (one for each SkuType), the result will contain 2 dummyWrapper instead + // of 1. + final ProductDetailsResponse response = await iapAndroidPlatform + .queryProductDetails(['invalid'].toSet()); + expect(response.notFoundIDs, ['invalid']); + expect(response.productDetails, isEmpty); + expect(response.error, isNotNull); + expect(response.error!.source, kIAPSource); + expect(response.error!.code, 'error_code'); + expect(response.error!.message, 'error_message'); + expect(response.error!.details, {'info': 'error_info'}); + }); + }); + + group('restorePurchases', () { + const String queryMethodName = 'BillingClient#queryPurchases(String)'; + test('handles error', () async { + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'purchasesList': >[] + }); + + expect( + iapAndroidPlatform.restorePurchases(), + throwsA( + isA() + .having((e) => e.source, 'source', kIAPSource) + .having((e) => e.code, 'code', kRestoredPurchaseErrorCode) + .having((e) => e.message, 'message', responseCode.toString()), + ), + ); + }); + + test('should store platform exception in the response', () async { + const String debugMessage = 'dummy message'; + + final BillingResponse responseCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: queryMethodName, + value: { + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'purchasesList': >[] + }, + additionalStepBeforeReturn: (_) { + throw PlatformException( + code: 'error_code', + message: 'error_message', + details: {'info': 'error_info'}, + ); + }); + + expect( + iapAndroidPlatform.restorePurchases(), + throwsA( + isA() + .having((e) => e.code, 'code', 'error_code') + .having((e) => e.message, 'message', 'error_message') + .having((e) => e.details, 'details', {'info': 'error_info'}), + ), + ); + }); + + test('returns SkuDetailsResponseWrapper', () async { + Completer completer = Completer(); + Stream> stream = iapAndroidPlatform.purchaseStream; + + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + if (purchaseDetailsList.first.status == PurchaseStatus.restored) { + completer.complete(purchaseDetailsList); + subscription.cancel(); + } + }); + + const String debugMessage = 'dummy message'; + final BillingResponse responseCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: responseCode, debugMessage: debugMessage); + + stubPlatform.addResponse(name: queryMethodName, value: { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(responseCode), + 'purchasesList': >[ + buildPurchaseMap(dummyPurchase), + ] + }); + + // Since queryPastPurchases makes 2 platform method calls (one for each + // SkuType), the result will contain 2 dummyPurchase instances instead + // of 1. + await iapAndroidPlatform.restorePurchases(); + final List restoredPurchases = await completer.future; + + expect(restoredPurchases.length, 2); + restoredPurchases.forEach((element) { + GooglePlayPurchaseDetails purchase = + element as GooglePlayPurchaseDetails; + + expect(purchase.productID, dummyPurchase.sku); + expect(purchase.purchaseID, dummyPurchase.orderId); + expect(purchase.verificationData.localVerificationData, + dummyPurchase.originalJson); + expect(purchase.verificationData.serverVerificationData, + dummyPurchase.purchaseToken); + expect(purchase.verificationData.source, kIAPSource); + expect(purchase.transactionDate, dummyPurchase.purchaseTime.toString()); + expect(purchase.billingClientPurchase, dummyPurchase); + expect(purchase.status, PurchaseStatus.restored); + }); + }); + }); + + group('make payment', () { + final String launchMethodName = + 'BillingClient#launchBillingFlow(Activity, BillingFlowParams)'; + const String consumeMethodName = + 'BillingClient#consumeAsync(String, ConsumeResponseListener)'; + + test('buy non consumable, serializes and deserializes data', () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + final bool launchResult = await iapAndroidPlatform.buyNonConsumable( + purchaseParam: purchaseParam); + + PurchaseDetails result = await completer.future; + expect(launchResult, isTrue); + expect(result.purchaseID, 'orderID1'); + expect(result.status, PurchaseStatus.purchased); + expect(result.productID, dummySkuDetails.sku); + }); + + test('handles an error with an empty purchases list', () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + await iapAndroidPlatform.buyNonConsumable(purchaseParam: purchaseParam); + PurchaseDetails result = await completer.future; + + expect(result.error, isNotNull); + expect(result.error!.source, kIAPSource); + expect(result.status, PurchaseStatus.error); + expect(result.purchaseID, isEmpty); + }); + + test('buy consumable with auto consume, serializes and deserializes data', + () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer consumeCompleter = Completer(); + // adding call back for consume purchase + final BillingResponse expectedCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResultForConsume), + additionalStepBeforeReturn: (dynamic args) { + String purchaseToken = args['purchaseToken']; + consumeCompleter.complete((purchaseToken)); + }); + + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + final bool launchResult = + await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); + + // Verify that the result has succeeded + GooglePlayPurchaseDetails result = await completer.future; + expect(launchResult, isTrue); + expect(result.billingClientPurchase, isNotNull); + expect(result.billingClientPurchase.purchaseToken, + await consumeCompleter.future); + expect(result.status, PurchaseStatus.purchased); + expect(result.error, isNull); + }); + + test('buyNonConsumable propagates failures to launch the billing flow', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult)); + + final bool result = await iapAndroidPlatform.buyNonConsumable( + purchaseParam: GooglePlayPurchaseParam( + productDetails: + GooglePlayProductDetails.fromSkuDetails(dummySkuDetails))); + + // Verify that the failure has been converted and returned + expect(result, isFalse); + }); + + test('buyConsumable propagates failures to launch the billing flow', + () async { + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + + final bool result = await iapAndroidPlatform.buyConsumable( + purchaseParam: GooglePlayPurchaseParam( + productDetails: + GooglePlayProductDetails.fromSkuDetails(dummySkuDetails))); + + // Verify that the failure has been converted and returned + expect(result, isFalse); + }); + + test('adds consumption failures to PurchaseDetails objects', () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer consumeCompleter = Completer(); + // adding call back for consume purchase + final BillingResponse expectedCode = BillingResponse.error; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResultForConsume), + additionalStepBeforeReturn: (dynamic args) { + String purchaseToken = args['purchaseToken']; + consumeCompleter.complete(purchaseToken); + }); + + Completer completer = Completer(); + PurchaseDetails purchaseDetails; + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + purchaseDetails = _.first; + completer.complete(purchaseDetails); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + await iapAndroidPlatform.buyConsumable(purchaseParam: purchaseParam); + + // Verify that the result has an error for the failed consumption + GooglePlayPurchaseDetails result = await completer.future; + expect(result.billingClientPurchase, isNotNull); + expect(result.billingClientPurchase.purchaseToken, + await consumeCompleter.future); + expect(result.status, PurchaseStatus.error); + expect(result.error, isNotNull); + expect(result.error!.code, kConsumptionFailedErrorCode); + }); + + test( + 'buy consumable without auto consume, consume api should not receive calls', + () async { + final SkuDetailsWrapper skuDetails = dummySkuDetails; + final String accountId = "hashedAccountId"; + const String debugMessage = 'dummy message'; + final BillingResponse sentCode = BillingResponse.developerError; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: sentCode, debugMessage: debugMessage); + + stubPlatform.addResponse( + name: launchMethodName, + value: buildBillingResultMap(expectedBillingResult), + additionalStepBeforeReturn: (_) { + // Mock java update purchase callback. + MethodCall call = MethodCall(kOnPurchasesUpdated, { + 'billingResult': buildBillingResultMap(expectedBillingResult), + 'responseCode': BillingResponseConverter().toJson(sentCode), + 'purchasesList': [ + { + 'orderId': 'orderID1', + 'sku': skuDetails.sku, + 'isAutoRenewing': false, + 'packageName': "package", + 'purchaseTime': 1231231231, + 'purchaseToken': "token", + 'signature': 'sign', + 'originalJson': 'json', + 'developerPayload': 'dummy payload', + 'isAcknowledged': true, + 'purchaseState': 1, + } + ] + }); + iapAndroidPlatform.billingClient.callHandler(call); + }); + Completer consumeCompleter = Completer(); + // adding call back for consume purchase + final BillingResponse expectedCode = BillingResponse.ok; + final BillingResultWrapper expectedBillingResultForConsume = + BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: consumeMethodName, + value: buildBillingResultMap(expectedBillingResultForConsume), + additionalStepBeforeReturn: (dynamic args) { + String purchaseToken = args['purchaseToken']; + consumeCompleter.complete((purchaseToken)); + }); + + Stream purchaseStream = iapAndroidPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = purchaseStream.listen((_) { + consumeCompleter.complete(null); + subscription.cancel(); + }, onDone: () {}); + final GooglePlayPurchaseParam purchaseParam = GooglePlayPurchaseParam( + productDetails: GooglePlayProductDetails.fromSkuDetails(skuDetails), + applicationUserName: accountId); + await iapAndroidPlatform.buyConsumable( + purchaseParam: purchaseParam, autoConsume: false); + expect(null, await consumeCompleter.future); + }); + }); + + group('complete purchase', () { + const String completeMethodName = + 'BillingClient#(AcknowledgePurchaseParams params, (AcknowledgePurchaseParams, AcknowledgePurchaseResponseListener)'; + test('complete purchase success', () async { + final BillingResponse expectedCode = BillingResponse.ok; + const String debugMessage = 'dummy message'; + final BillingResultWrapper expectedBillingResult = BillingResultWrapper( + responseCode: expectedCode, debugMessage: debugMessage); + stubPlatform.addResponse( + name: completeMethodName, + value: buildBillingResultMap(expectedBillingResult), + ); + PurchaseDetails purchaseDetails = + GooglePlayPurchaseDetails.fromPurchase(dummyUnacknowledgedPurchase); + Completer completer = Completer(); + purchaseDetails.status = PurchaseStatus.purchased; + if (purchaseDetails.pendingCompletePurchase) { + final BillingResultWrapper billingResultWrapper = + await iapAndroidPlatform.completePurchase(purchaseDetails); + expect(billingResultWrapper, equals(expectedBillingResult)); + completer.complete(billingResultWrapper); + } + expect(await completer.future, equals(expectedBillingResult)); + }); + }); +} diff --git a/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart b/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.dart new file mode 100644 index 000000000000..11a3426335d5 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_android/test/stub_in_app_purchase_platform.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:async'; +import 'package:flutter/services.dart'; + +typedef void AdditionalSteps(dynamic args); + +class StubInAppPurchasePlatform { + Map _expectedCalls = {}; + Map _additionalSteps = {}; + void addResponse( + {required String name, + dynamic value, + AdditionalSteps? additionalStepBeforeReturn}) { + _additionalSteps[name] = additionalStepBeforeReturn; + _expectedCalls[name] = value; + } + + List _previousCalls = []; + List get previousCalls => _previousCalls; + MethodCall previousCallMatching(String name) => + _previousCalls.firstWhere((MethodCall call) => call.method == name); + int countPreviousCalls(String name) => + _previousCalls.where((MethodCall call) => call.method == name).length; + + void reset() { + _expectedCalls.clear(); + _previousCalls.clear(); + _additionalSteps.clear(); + } + + Future fakeMethodCallHandler(MethodCall call) async { + _previousCalls.add(call); + if (_expectedCalls.containsKey(call.method)) { + if (_additionalSteps[call.method] != null) { + _additionalSteps[call.method]!(call.arguments); + } + return Future.sync(() => _expectedCalls[call.method]); + } else { + return Future.sync(() => null); + } + } +} diff --git a/packages/in_app_purchase/in_app_purchase_ios/README.md b/packages/in_app_purchase/in_app_purchase_ios/README.md index 025ed36b72a6..3ab033849f39 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/README.md +++ b/packages/in_app_purchase/in_app_purchase_ios/README.md @@ -31,4 +31,7 @@ dependencies: ... ``` +## TODO +- [ ] Add an example application demonstrating the use of the [in_app_purchase_ios] package (see also issue [flutter/flutter#81695](https://github.com/flutter/flutter/issues/81695)). + [1]: ../in_app_purchase/in_app_purchase \ No newline at end of file diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart index bb2fd2b3639a..a83c88796343 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/in_app_purchase_ios_platform.dart @@ -23,10 +23,6 @@ const String kIAPSource = 'app_store'; /// This translates various `StoreKit` calls and responses into the /// generic plugin API. class InAppPurchaseIosPlatform extends InAppPurchasePlatform { - /// Returns the singleton instance of the [InAppPurchaseIosPlatform] that should be - /// used across the app. - static InAppPurchaseIosPlatform get instance => _getOrCreateInstance(); - static InAppPurchaseIosPlatform? _instance; static late SKPaymentQueueWrapper _skPaymentQueueWrapper; static late _TransactionObserver _observer; @@ -44,22 +40,19 @@ class InAppPurchaseIosPlatform extends InAppPurchasePlatform { @visibleForTesting static SKTransactionObserverWrapper get observer => _observer; - static InAppPurchaseIosPlatform _getOrCreateInstance() { - if (_instance != null) { - return _instance!; - } - + /// Registers this class as the default instance of [InAppPurchasePlatform]. + static void registerPlatform() { // Register the [InAppPurchaseIosPlatformAddition] containing iOS // platform-specific functionality. InAppPurchasePlatformAddition.instance = InAppPurchaseIosPlatformAddition(); // Register the platform-specific implementation of the idiomatic // InAppPurchase API. - _instance = InAppPurchaseIosPlatform(); + InAppPurchasePlatform.instance = InAppPurchaseIosPlatform(); + _skPaymentQueueWrapper = SKPaymentQueueWrapper(); _observer = _TransactionObserver(StreamController.broadcast()); _skPaymentQueueWrapper.setTransactionObserver(observer); - return _instance!; } @override diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart index a70e2d9191bb..b15249c81947 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart @@ -18,19 +18,24 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); final FakeIOSPlatform fakeIOSPlatform = FakeIOSPlatform(); + late InAppPurchaseIosPlatform iapIosPlatform; setUpAll(() { SystemChannels.platform .setMockMethodCallHandler(fakeIOSPlatform.onMethodCall); }); - setUp(() => fakeIOSPlatform.reset()); + setUp(() { + InAppPurchaseIosPlatform.registerPlatform(); + iapIosPlatform = InAppPurchasePlatform.instance as InAppPurchaseIosPlatform; + fakeIOSPlatform.reset(); + }); tearDown(() => fakeIOSPlatform.reset()); group('isAvailable', () { test('true', () async { - expect(await InAppPurchaseIosPlatform.instance.isAvailable(), isTrue); + expect(await iapIosPlatform.isAvailable(), isTrue); }); }); @@ -69,8 +74,7 @@ void main() { group('restore purchases', () { test('should emit restored transactions on purchase stream', () async { Completer completer = Completer(); - Stream> stream = - InAppPurchaseIosPlatform.instance.purchaseStream; + Stream> stream = iapIosPlatform.purchaseStream; late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { @@ -80,7 +84,7 @@ void main() { } }); - await InAppPurchaseIosPlatform.instance.restorePurchases(); + await iapIosPlatform.restorePurchases(); List details = await completer.future; expect(details.length, 2); @@ -103,8 +107,7 @@ void main() { fakeIOSPlatform.transactions .insert(0, fakeIOSPlatform.createPurchasedTransaction('foo', 'bar')); Completer completer = Completer(); - Stream> stream = - InAppPurchaseIosPlatform.instance.purchaseStream; + Stream> stream = iapIosPlatform.purchaseStream; late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { @@ -113,7 +116,7 @@ void main() { subscription.cancel(); } }); - await InAppPurchaseIosPlatform.instance.restorePurchases(); + await iapIosPlatform.restorePurchases(); List details = await completer.future; expect(details.length, 3); for (int i = 0; i < fakeIOSPlatform.transactions.length; i++) { @@ -139,8 +142,7 @@ void main() { () async { fakeIOSPlatform.receiptData = null; Completer completer = Completer(); - Stream> stream = - InAppPurchaseIosPlatform.instance.purchaseStream; + Stream> stream = iapIosPlatform.purchaseStream; late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { @@ -150,7 +152,7 @@ void main() { } }); - await InAppPurchaseIosPlatform.instance.restorePurchases(); + await iapIosPlatform.restorePurchases(); List details = await completer.future; for (PurchaseDetails purchase in details) { @@ -166,7 +168,7 @@ void main() { userInfo: {'message': 'errorMessage'}); expect( - () => InAppPurchaseIosPlatform.instance.restorePurchases(), + () => iapIosPlatform.restorePurchases(), throwsA( isA() .having((error) => error.code, 'code', 123) @@ -183,8 +185,7 @@ void main() { () async { List details = []; Completer completer = Completer(); - Stream> stream = - InAppPurchaseIosPlatform.instance.purchaseStream; + Stream> stream = iapIosPlatform.purchaseStream; late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { @@ -198,8 +199,7 @@ void main() { productDetails: AppStoreProductDetails.fromSKProduct(dummyProductWrapper), applicationUserName: 'appName'); - await InAppPurchaseIosPlatform.instance - .buyNonConsumable(purchaseParam: purchaseParam); + await iapIosPlatform.buyNonConsumable(purchaseParam: purchaseParam); List result = await completer.future; expect(result.length, 2); @@ -211,8 +211,7 @@ void main() { () async { List details = []; Completer completer = Completer(); - Stream> stream = - InAppPurchaseIosPlatform.instance.purchaseStream; + Stream> stream = iapIosPlatform.purchaseStream; late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { @@ -226,8 +225,7 @@ void main() { productDetails: AppStoreProductDetails.fromSKProduct(dummyProductWrapper), applicationUserName: 'appName'); - await InAppPurchaseIosPlatform.instance - .buyConsumable(purchaseParam: purchaseParam); + await iapIosPlatform.buyConsumable(purchaseParam: purchaseParam); List result = await completer.future; expect(result.length, 2); @@ -240,8 +238,8 @@ void main() { AppStoreProductDetails.fromSKProduct(dummyProductWrapper), applicationUserName: 'appName'); expect( - () => InAppPurchaseIosPlatform.instance - .buyConsumable(purchaseParam: purchaseParam, autoConsume: false), + () => iapIosPlatform.buyConsumable( + purchaseParam: purchaseParam, autoConsume: false), throwsA(isInstanceOf())); }); @@ -251,8 +249,7 @@ void main() { Completer completer = Completer(); late IAPError error; - Stream> stream = - InAppPurchaseIosPlatform.instance.purchaseStream; + Stream> stream = iapIosPlatform.purchaseStream; late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { details.addAll(purchaseDetailsList); @@ -268,8 +265,7 @@ void main() { productDetails: AppStoreProductDetails.fromSKProduct(dummyProductWrapper), applicationUserName: 'appName'); - await InAppPurchaseIosPlatform.instance - .buyNonConsumable(purchaseParam: purchaseParam); + await iapIosPlatform.buyNonConsumable(purchaseParam: purchaseParam); IAPError completerError = await completer.future; expect(completerError.code, 'purchase_error'); @@ -283,14 +279,13 @@ void main() { test('should complete purchase', () async { List details = []; Completer completer = Completer(); - Stream> stream = - InAppPurchaseIosPlatform.instance.purchaseStream; + Stream> stream = iapIosPlatform.purchaseStream; late StreamSubscription subscription; subscription = stream.listen((purchaseDetailsList) { details.addAll(purchaseDetailsList); purchaseDetailsList.forEach((purchaseDetails) { if (purchaseDetails.pendingCompletePurchase) { - InAppPurchaseIosPlatform.instance.completePurchase(purchaseDetails); + iapIosPlatform.completePurchase(purchaseDetails); completer.complete(details); subscription.cancel(); } @@ -300,8 +295,7 @@ void main() { productDetails: AppStoreProductDetails.fromSKProduct(dummyProductWrapper), applicationUserName: 'appName'); - await InAppPurchaseIosPlatform.instance - .buyNonConsumable(purchaseParam: purchaseParam); + await iapIosPlatform.buyNonConsumable(purchaseParam: purchaseParam); List result = await completer.future; expect(result.length, 2); expect(result.first.productID, dummyProductWrapper.productIdentifier); diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/in_app_purchase_platform_interface.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/in_app_purchase_platform_interface.dart index 9e12a9dd33e4..25eb4a44c4b4 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/in_app_purchase_platform_interface.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/in_app_purchase_platform_interface.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/errors/errors.dart'; export 'src/in_app_purchase_platform.dart'; export 'src/in_app_purchase_platform_addition.dart'; export 'src/in_app_purchase_platform_addition_provider.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/errors.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/errors.dart new file mode 100644 index 000000000000..7b788aaef490 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/errors.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 'in_app_purchase_exception.dart'; diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_exception.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_exception.dart new file mode 100644 index 000000000000..0a89a6e39a5e --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/errors/in_app_purchase_exception.dart @@ -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. + +/// Thrown to indicate that an action failed while interacting with the +/// in_app_purchase plugin. +class InAppPurchaseException implements Exception { + /// Creates a [InAppPurchaseException] with the specified source and error + /// [code] and optional [message]. + InAppPurchaseException({ + required this.source, + required this.code, + this.message, + }) : assert(code != null); + + /// An error code. + final String code; + + /// A human-readable error message, possibly null. + final String? message; + + /// Which source is the error on. + final String source; + + @override + String toString() => 'InAppPurchaseException($code, $message, $source)'; +} diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart index f8dc4c998494..eac4a0712078 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform.dart @@ -23,27 +23,28 @@ abstract class InAppPurchasePlatform extends PlatformInterface { /// The instance of [InAppPurchasePlatform] to use. /// - /// Defaults to `null`. - static InAppPurchasePlatform? get instance => _instance; - - static InAppPurchasePlatform? _instance; + /// Must be set before accessing. + static InAppPurchasePlatform get instance => _instance; /// Platform-specific plugins should set this with their own platform-specific /// class that extends [InAppPurchasePlatform] when they register themselves. // TODO(amirh): Extract common platform interface logic. // https://github.com/flutter/flutter/issues/43368 - static void setInstance(InAppPurchasePlatform instance) { + static set instance(InAppPurchasePlatform instance) { PlatformInterface.verifyToken(instance, _token); _instance = instance; } + // Should only be accessed after setter is called. + static late InAppPurchasePlatform _instance; + /// Listen to this broadcast stream to get real time update for purchases. /// /// This stream will never close as long as the app is active. /// /// Purchase updates can happen in several situations: /// * When a purchase is triggered by user in the app. - /// * When a purchase is triggered by user from the platform specific store front. + /// * When a purchase is triggered by user from the platform-specific store front. /// * When a purchase is restored on the device by the user in the app. /// * If a purchase is not completed ([completePurchase] is not called on the /// purchase object) from the last app session. Purchase updates will happen diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition.dart index 5c41f138ecea..746675549295 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition.dart @@ -2,18 +2,33 @@ // 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_platform_interface/in_app_purchase_platform_interface.dart'; + // ignore: avoid_classes_with_only_static_members /// The interface that platform implementations must implement when they want to -/// provide platform specific in_app_purchase features. +/// provide platform-specific in_app_purchase features. +/// +/// Platforms that wants to introduce platform-specific public APIs should create +/// a class that either extend or implements [InAppPurchasePlatformAddition]. Then set +/// the [InAppPurchasePlatformAddition.instance] to an instance of that class. +/// +/// All the APIs added by [InAppPurchasePlatformAddition] implementations will be accessed from +/// [InAppPurchasePlatformAdditionProvider.getPlatformAddition] by the client APPs. +/// To avoid clients directly calling [InAppPurchasePlatform] APIs, +/// an [InAppPurchasePlatformAddition] implementation should not be a type of [InAppPurchasePlatform]. abstract class InAppPurchasePlatformAddition { + static InAppPurchasePlatformAddition? _instance; + /// The instance containing the platform-specific in_app_purchase /// functionality. /// + /// Returns `null` by default. + /// /// To implement additional functionality extend /// [`InAppPurchasePlatformAddition`][3] with the platform-specific /// functionality, and when the plugin is registered, set the /// `InAppPurchasePlatformAddition.instance` with the new addition - /// implementationinstance. + /// implementation instance. /// /// Example implementation might look like this: /// ```dart @@ -22,7 +37,7 @@ abstract class InAppPurchasePlatformAddition { /// } /// ``` /// - /// The following snippit shows how to register the `InAppPurchaseMyPlatformAddition`: + /// The following snippet shows how to register the `InAppPurchaseMyPlatformAddition`: /// ```dart /// class InAppPurchaseMyPlatformPlugin { /// static void registerWith(Registrar registrar) { @@ -36,5 +51,13 @@ abstract class InAppPurchasePlatformAddition { /// } /// } /// ``` - static InAppPurchasePlatformAddition? instance; + static InAppPurchasePlatformAddition? get instance => _instance; + + /// Sets the instance to a desired [InAppPurchasePlatformAddition] implementation. + /// + /// The `instance` should not be a type of [InAppPurchasePlatform]. + static set instance(InAppPurchasePlatformAddition? instance) { + assert(instance is! InAppPurchasePlatform); + _instance = instance; + } } diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition_provider.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition_provider.dart index d981f73b4019..642bbb419c6e 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition_provider.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/in_app_purchase_platform_addition_provider.dart @@ -5,13 +5,13 @@ import 'package:in_app_purchase_platform_interface/src/in_app_purchase_platform_addition.dart'; /// The [InAppPurchasePlatformAdditionProvider] is responsible for providing -/// a platform specific [InAppPurchasePlatformAddition]. +/// a platform-specific [InAppPurchasePlatformAddition]. /// -/// [InAppPurchasePlatformAddition] implementation contain platform specific +/// [InAppPurchasePlatformAddition] implementation contain platform-specific /// features that are not available from the platform idiomatic /// [InAppPurchasePlatform] API. abstract class InAppPurchasePlatformAdditionProvider { - /// Provides a platform specific implementation of the [InAppPurchasePlatformAddition] + /// Provides a platform-specific implementation of the [InAppPurchasePlatformAddition] /// class. - T getPlatformAddition(); + T getPlatformAddition(); } diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart index 868f9428add2..11b244a84ae3 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/lib/src/types/product_details_response.dart @@ -18,7 +18,7 @@ class ProductDetailsResponse { /// The list of identifiers that are in the `identifiers` of [InAppPurchasePlatform.queryProductDetails] but failed to be fetched. /// - /// There's multiple platform specific reasons that product information could fail to be fetched, + /// There are multiple platform-specific reasons that product information could fail to be fetched, /// ranging from products not being correctly configured in the storefront to the queried IDs not existing. final List notFoundIDs; diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/test/in_app_purchase_platform_test.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/test/in_app_purchase_platform_test.dart index d5c1ae5fc127..9c0f2dc00020 100644 --- a/packages/in_app_purchase/in_app_purchase_platform_interface/test/in_app_purchase_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/test/in_app_purchase_platform_test.dart @@ -11,22 +11,18 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('$InAppPurchasePlatform', () { - test('Default instance should return null', () { - expect(InAppPurchasePlatform.instance, null); - }); - test('Cannot be implemented with `implements`', () { expect(() { - InAppPurchasePlatform.setInstance(ImplementsInAppPurchasePlatform()); + InAppPurchasePlatform.instance = ImplementsInAppPurchasePlatform(); }, throwsNoSuchMethodError); }); test('Can be extended', () { - InAppPurchasePlatform.setInstance(ExtendsInAppPurchasePlatform()); + InAppPurchasePlatform.instance = ExtendsInAppPurchasePlatform(); }); test('Can be mocked with `implements`', () { - InAppPurchasePlatform.setInstance(MockInAppPurchasePlatform()); + InAppPurchasePlatform.instance = MockInAppPurchasePlatform(); }); test( @@ -124,6 +120,50 @@ void main() { ); }); }); + + group('$InAppPurchasePlatformAddition', () { + setUp(() { + InAppPurchasePlatformAddition.instance = null; + }); + + test('Cannot be implemented with `implements`', () { + expect(InAppPurchasePlatformAddition.instance, isNull); + }); + + test('Can be implemented.', () { + InAppPurchasePlatformAddition.instance = + ImplementsInAppPurchasePlatformAddition(); + }); + + test('InAppPurchasePlatformAddition Can be extended', () { + InAppPurchasePlatformAddition.instance = + ExtendsInAppPurchasePlatformAddition(); + }); + + test('Can not be a `InAppPurchasePlatform`', () { + expect( + () => InAppPurchasePlatformAddition.instance = + ExtendsInAppPurchasePlatformAdditionIsPlatformInterface(), + throwsAssertionError); + }); + + test('Provider can provide', () { + ImplementsInAppPurchasePlatformAdditionProvider.register(); + final ImplementsInAppPurchasePlatformAdditionProvider provider = + ImplementsInAppPurchasePlatformAdditionProvider(); + final InAppPurchasePlatformAddition? addition = + provider.getPlatformAddition(); + expect(addition.runtimeType, ExtendsInAppPurchasePlatformAddition); + }); + + test('Provider can provide `null`', () { + final ImplementsInAppPurchasePlatformAdditionProvider provider = + ImplementsInAppPurchasePlatformAdditionProvider(); + final InAppPurchasePlatformAddition? addition = + provider.getPlatformAddition(); + expect(addition, isNull); + }); + }); } class ImplementsInAppPurchasePlatform implements InAppPurchasePlatform { @@ -143,3 +183,29 @@ class ExtendsInAppPurchasePlatform extends InAppPurchasePlatform {} class MockPurchaseParam extends Mock implements PurchaseParam {} class MockPurchaseDetails extends Mock implements PurchaseDetails {} + +class ImplementsInAppPurchasePlatformAddition + implements InAppPurchasePlatformAddition { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class ExtendsInAppPurchasePlatformAddition + extends InAppPurchasePlatformAddition {} + +class ImplementsInAppPurchasePlatformAdditionProvider + implements InAppPurchasePlatformAdditionProvider { + static void register() { + InAppPurchasePlatformAddition.instance = + ExtendsInAppPurchasePlatformAddition(); + } + + @override + T getPlatformAddition() { + return InAppPurchasePlatformAddition.instance as T; + } +} + +class ExtendsInAppPurchasePlatformAdditionIsPlatformInterface + extends InAppPurchasePlatform + implements ExtendsInAppPurchasePlatformAddition {} diff --git a/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/errors/in_app_purchase_exception_test.dart b/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/errors/in_app_purchase_exception_test.dart new file mode 100644 index 000000000000..ff9468ec2d88 --- /dev/null +++ b/packages/in_app_purchase/in_app_purchase_platform_interface/test/src/errors/in_app_purchase_exception_test.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:flutter_test/flutter_test.dart'; +import 'package:in_app_purchase_platform_interface/src/errors/in_app_purchase_exception.dart'; + +void main() { + test('toString: Should return a description of the exception', () { + final InAppPurchaseException exception = InAppPurchaseException( + code: 'error_code', + message: 'dummy message', + source: 'dummy_source', + ); + + // Act + final String actual = exception.toString(); + + // Assert + expect(actual, + 'InAppPurchaseException(error_code, dummy message, dummy_source)'); + }); +} diff --git a/packages/integration_test/example/macos/Podfile b/packages/integration_test/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/integration_test/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/integration_test/example/test_driver/integration_test.dart b/packages/integration_test/example/test_driver/integration_test.dart index 4f10f2a522f3..6a0e6fa82dbe 100644 --- a/packages/integration_test/example/test_driver/integration_test.dart +++ b/packages/integration_test/example/test_driver/integration_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// @dart=2.9 + import 'package:integration_test/integration_test_driver.dart'; Future main() => integrationDriver(); diff --git a/packages/local_auth/integration_test/local_auth_test.dart b/packages/local_auth/example/integration_test/local_auth_test.dart similarity index 100% rename from packages/local_auth/integration_test/local_auth_test.dart rename to packages/local_auth/example/integration_test/local_auth_test.dart diff --git a/packages/local_auth/example/test_driver/integration_test.dart b/packages/local_auth/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/local_auth/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/package_info/example/macos/Podfile b/packages/package_info/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/package_info/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/package_info/example/test_driver/integration_test.dart b/packages/package_info/example/test_driver/integration_test.dart index 1bcccae039d6..6a0e6fa82dbe 100644 --- a/packages/package_info/example/test_driver/integration_test.dart +++ b/packages/package_info/example/test_driver/integration_test.dart @@ -4,18 +4,6 @@ // @dart=2.9 -import 'dart:convert'; -import 'dart:io'; +import 'package:integration_test/integration_test_driver.dart'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = await driver.requestData( - null, - timeout: const Duration(minutes: 1), - ); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/path_provider/path_provider/example/macos/Podfile b/packages/path_provider/path_provider/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/path_provider/path_provider/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/path_provider/path_provider/example/test_driver/integration_test.dart b/packages/path_provider/path_provider/example/test_driver/integration_test.dart index 24a0ee720b2a..6a0e6fa82dbe 100644 --- a/packages/path_provider/path_provider/example/test_driver/integration_test.dart +++ b/packages/path_provider/path_provider/example/test_driver/integration_test.dart @@ -4,15 +4,6 @@ // @dart=2.9 -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data) as Map; - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/path_provider/path_provider/integration_test/path_provider_test.dart b/packages/path_provider/path_provider/integration_test/path_provider_test.dart deleted file mode 100644 index 71550682444c..000000000000 --- a/packages/path_provider/path_provider/integration_test/path_provider_test.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. - -// @dart=2.9 - -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:path_provider/path_provider.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('Can get temporary directory', (WidgetTester tester) async { - final String tempPath = (await getTemporaryDirectory()).path; - expect(tempPath, isNotEmpty); - }); -} diff --git a/packages/path_provider/path_provider_linux/example/test_driver/integration_test.dart b/packages/path_provider/path_provider_linux/example/test_driver/integration_test.dart index 24a0ee720b2a..6a0e6fa82dbe 100644 --- a/packages/path_provider/path_provider_linux/example/test_driver/integration_test.dart +++ b/packages/path_provider/path_provider_linux/example/test_driver/integration_test.dart @@ -4,15 +4,6 @@ // @dart=2.9 -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data) as Map; - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/path_provider/path_provider_macos/example/macos/Podfile b/packages/path_provider/path_provider_macos/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/path_provider/path_provider_macos/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/path_provider/path_provider_macos/example/test_driver/integration_test.dart b/packages/path_provider/path_provider_macos/example/test_driver/integration_test.dart index 24a0ee720b2a..6a0e6fa82dbe 100644 --- a/packages/path_provider/path_provider_macos/example/test_driver/integration_test.dart +++ b/packages/path_provider/path_provider_macos/example/test_driver/integration_test.dart @@ -4,15 +4,6 @@ // @dart=2.9 -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data) as Map; - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/path_provider/path_provider_windows/example/test_driver/integration_test.dart b/packages/path_provider/path_provider_windows/example/test_driver/integration_test.dart index 24a0ee720b2a..6a0e6fa82dbe 100644 --- a/packages/path_provider/path_provider_windows/example/test_driver/integration_test.dart +++ b/packages/path_provider/path_provider_windows/example/test_driver/integration_test.dart @@ -4,15 +4,6 @@ // @dart=2.9 -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data) as Map; - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/quick_actions/quick_actions/example/test_driver/integration_test.dart b/packages/quick_actions/quick_actions/example/test_driver/integration_test.dart index 4c4c006068b8..6a0e6fa82dbe 100644 --- a/packages/quick_actions/quick_actions/example/test_driver/integration_test.dart +++ b/packages/quick_actions/quick_actions/example/test_driver/integration_test.dart @@ -2,17 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +// @dart=2.9 -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/sensors/integration_test/sensors_test.dart b/packages/sensors/example/integration_test/sensors_test.dart similarity index 100% rename from packages/sensors/integration_test/sensors_test.dart rename to packages/sensors/example/integration_test/sensors_test.dart diff --git a/packages/sensors/example/test_driver/integration_test.dart b/packages/sensors/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/sensors/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/sensors/example/test_driver/test/integration_test.dart b/packages/sensors/example/test_driver/test/integration_test.dart deleted file mode 100644 index 257b0d3c0930..000000000000 --- a/packages/sensors/example/test_driver/test/integration_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// @dart = 2.9 - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/packages/share/integration_test/share_test.dart b/packages/share/example/integration_test/share_test.dart similarity index 100% rename from packages/share/integration_test/share_test.dart rename to packages/share/example/integration_test/share_test.dart diff --git a/packages/share/example/test_driver/integration_test.dart b/packages/share/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/share/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/share/example/test_driver/test/integration_test.dart b/packages/share/example/test_driver/test/integration_test.dart deleted file mode 100644 index 257b0d3c0930..000000000000 --- a/packages/share/example/test_driver/test/integration_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// @dart = 2.9 - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/packages/shared_preferences/shared_preferences/example/macos/Podfile b/packages/shared_preferences/shared_preferences/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/shared_preferences/shared_preferences/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/shared_preferences/shared_preferences/example/test_driver/integration_test.dart b/packages/shared_preferences/shared_preferences/example/test_driver/integration_test.dart index 18ed3cff3ee8..6a0e6fa82dbe 100644 --- a/packages/shared_preferences/shared_preferences/example/test_driver/integration_test.dart +++ b/packages/shared_preferences/shared_preferences/example/test_driver/integration_test.dart @@ -4,16 +4,6 @@ // @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/shared_preferences/shared_preferences_linux/example/test_driver/integration_test.dart b/packages/shared_preferences/shared_preferences_linux/example/test_driver/integration_test.dart index 18ed3cff3ee8..6a0e6fa82dbe 100644 --- a/packages/shared_preferences/shared_preferences_linux/example/test_driver/integration_test.dart +++ b/packages/shared_preferences/shared_preferences_linux/example/test_driver/integration_test.dart @@ -4,16 +4,6 @@ // @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/shared_preferences/shared_preferences_macos/example/macos/Podfile b/packages/shared_preferences/shared_preferences_macos/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/shared_preferences/shared_preferences_macos/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/shared_preferences/shared_preferences_macos/example/test_driver/integration_test.dart b/packages/shared_preferences/shared_preferences_macos/example/test_driver/integration_test.dart index 18ed3cff3ee8..6a0e6fa82dbe 100644 --- a/packages/shared_preferences/shared_preferences_macos/example/test_driver/integration_test.dart +++ b/packages/shared_preferences/shared_preferences_macos/example/test_driver/integration_test.dart @@ -4,16 +4,6 @@ // @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/shared_preferences/shared_preferences_windows/example/test_driver/integration_test.dart b/packages/shared_preferences/shared_preferences_windows/example/test_driver/integration_test.dart index 18ed3cff3ee8..6a0e6fa82dbe 100644 --- a/packages/shared_preferences/shared_preferences_windows/example/test_driver/integration_test.dart +++ b/packages/shared_preferences/shared_preferences_windows/example/test_driver/integration_test.dart @@ -4,16 +4,6 @@ // @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/url_launcher/url_launcher/example/macos/Podfile b/packages/url_launcher/url_launcher/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/url_launcher/url_launcher/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/url_launcher/url_launcher/example/test_driver/integration_test.dart b/packages/url_launcher/url_launcher/example/test_driver/integration_test.dart index 053e985a78ce..6a0e6fa82dbe 100644 --- a/packages/url_launcher/url_launcher/example/test_driver/integration_test.dart +++ b/packages/url_launcher/url_launcher/example/test_driver/integration_test.dart @@ -2,19 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// TODO(egarciad): Remove once flutter_driver is migrated to null safety. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/url_launcher/url_launcher_linux/example/test_driver/integration_test.dart b/packages/url_launcher/url_launcher_linux/example/test_driver/integration_test.dart index 257b0d3c0930..6a0e6fa82dbe 100644 --- a/packages/url_launcher/url_launcher_linux/example/test_driver/integration_test.dart +++ b/packages/url_launcher/url_launcher_linux/example/test_driver/integration_test.dart @@ -2,18 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/url_launcher/url_launcher_macos/example/macos/Podfile b/packages/url_launcher/url_launcher_macos/example/macos/Podfile new file mode 100644 index 000000000000..dade8dfad0dc --- /dev/null +++ b/packages/url_launcher/url_launcher_macos/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# 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', 'ephemeral', '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 Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/url_launcher/url_launcher_macos/example/test_driver/integration_test.dart b/packages/url_launcher/url_launcher_macos/example/test_driver/integration_test.dart index 257b0d3c0930..6a0e6fa82dbe 100644 --- a/packages/url_launcher/url_launcher_macos/example/test_driver/integration_test.dart +++ b/packages/url_launcher/url_launcher_macos/example/test_driver/integration_test.dart @@ -2,18 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/url_launcher/url_launcher_web/example/test_driver/integration_test.dart b/packages/url_launcher/url_launcher_web/example/test_driver/integration_test.dart index f26b6a310cfe..4f10f2a522f3 100644 --- a/packages/url_launcher/url_launcher_web/example/test_driver/integration_test.dart +++ b/packages/url_launcher/url_launcher_web/example/test_driver/integration_test.dart @@ -4,4 +4,4 @@ import 'package:integration_test/integration_test_driver.dart'; -Future main() async => integrationDriver(); +Future main() => integrationDriver(); diff --git a/packages/url_launcher/url_launcher_windows/example/test_driver/integration_test.dart b/packages/url_launcher/url_launcher_windows/example/test_driver/integration_test.dart index 257b0d3c0930..6a0e6fa82dbe 100644 --- a/packages/url_launcher/url_launcher_windows/example/test_driver/integration_test.dart +++ b/packages/url_launcher/url_launcher_windows/example/test_driver/integration_test.dart @@ -2,18 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/video_player/video_player/CONTRIBUTING.md b/packages/video_player/video_player/CONTRIBUTING.md index 32c9d1b791d1..15c48038f6fc 100644 --- a/packages/video_player/video_player/CONTRIBUTING.md +++ b/packages/video_player/video_player/CONTRIBUTING.md @@ -8,7 +8,7 @@ dependencies in the examples directory): flutter pub upgrade flutter pub run pigeon --dart_null_safety --input pigeons/messages.dart # git commit your changes so that your working environment is clean -(cd ../../../; ./script/incremental_build.sh format --travis --clang-format=clang-format-7) +(cd ../../../; ./script/tool_runner.sh format --clang-format=clang-format-7) ``` If you update pigeon itself and want to test the changes here, diff --git a/packages/video_player/video_player/example/test_driver/integration_test.dart b/packages/video_player/video_player/example/test_driver/integration_test.dart index 6a3ccada0232..6a0e6fa82dbe 100644 --- a/packages/video_player/video_player/example/test_driver/integration_test.dart +++ b/packages/video_player/video_player/example/test_driver/integration_test.dart @@ -2,19 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// TODO(egarciad): Remove once Flutter driver is migrated to null safety. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/webview_flutter/example/test_driver/integration_test.dart b/packages/webview_flutter/example/test_driver/integration_test.dart index 257b0d3c0930..6a0e6fa82dbe 100644 --- a/packages/webview_flutter/example/test_driver/integration_test.dart +++ b/packages/webview_flutter/example/test_driver/integration_test.dart @@ -2,18 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// @dart = 2.9 +// @dart=2.9 -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; +import 'package:integration_test/integration_test_driver.dart'; -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} +Future main() => integrationDriver(); diff --git a/packages/wifi_info_flutter/wifi_info_flutter/integration_test/wifi_info_test.dart b/packages/wifi_info_flutter/wifi_info_flutter/example/integration_test/wifi_info_test.dart similarity index 100% rename from packages/wifi_info_flutter/wifi_info_flutter/integration_test/wifi_info_test.dart rename to packages/wifi_info_flutter/wifi_info_flutter/example/integration_test/wifi_info_test.dart diff --git a/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/integration_test.dart b/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..6a0e6fa82dbe --- /dev/null +++ b/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/integration_test.dart @@ -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. + +// @dart=2.9 + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/integration_test/wifi_info_test.dart b/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/integration_test/wifi_info_test.dart deleted file mode 100644 index 103be52aa56b..000000000000 --- a/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/integration_test/wifi_info_test.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. - -// @dart = 2.9 -import 'dart:io'; -import 'package:integration_test/integration_test.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:wifi_info_flutter/wifi_info_flutter.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('$WifiInfo test driver', () { - WifiInfo _wifiInfo; - - setUpAll(() async { - _wifiInfo = WifiInfo(); - }); - - testWidgets('test location methods, iOS only', (WidgetTester tester) async { - expect( - (await _wifiInfo.getLocationServiceAuthorization()), - LocationAuthorizationStatus.notDetermined, - ); - }, skip: !Platform.isIOS); - }); -} diff --git a/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/test/integration_test.dart b/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/test/integration_test.dart deleted file mode 100644 index 9647a12d77ce..000000000000 --- a/packages/wifi_info_flutter/wifi_info_flutter/example/test_driver/test/integration_test.dart +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// @dart = 2.9 -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; - -Future main() async { - final FlutterDriver driver = await FlutterDriver.connect(); - final String data = - await driver.requestData(null, timeout: const Duration(minutes: 1)); - await driver.close(); - final Map result = jsonDecode(data); - exit(result['result'] == 'true' ? 0 : 1); -} diff --git a/script/build_all_plugins_app.sh b/script/build_all_plugins_app.sh index 06566f059a54..3b3416021a42 100755 --- a/script/build_all_plugins_app.sh +++ b/script/build_all_plugins_app.sh @@ -18,8 +18,6 @@ readonly REPO_DIR="$(dirname "$SCRIPT_DIR")" source "$SCRIPT_DIR/common.sh" -check_changed_packages > /dev/null - # 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. @@ -63,18 +61,10 @@ for version in "${BUILD_MODES[@]}"; do if [ $? -eq 0 ]; then echo "Successfully built $version all_plugins app." - echo "All first party plugins compile together." + echo "All first-party plugins compile together." else error "Failed to build $version all_plugins app." - if [[ "${#CHANGED_PACKAGE_LIST[@]}" == 0 ]]; then - error "There was a failure to compile all first party plugins together, but there were no changes detected in packages." - else - error "Changes to the following packages may prevent all first party plugins from compiling together:" - for package in "${CHANGED_PACKAGE_LIST[@]}"; do - error "$package" - done - echo "" - fi + error "This indicates a conflict between two or more first-party plugins." failures=$(($failures + 1)) fi done diff --git a/script/check_publish.sh b/script/check_publish.sh deleted file mode 100755 index a08df7a0b5d8..000000000000 --- a/script/check_publish.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -# Copyright 2013 The Flutter Authors. All rights reserved. -# Use of this source code is governed by a BSD-style license that can be -# found in the LICENSE file. - -set -e - -# This script checks to make sure that each of the plugins *could* be published. -# It doesn't actually publish anything. - -# So that users can run this script from anywhere and it will work as expected. -readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -readonly REPO_DIR="$(dirname "$SCRIPT_DIR")" - -source "$SCRIPT_DIR/common.sh" - -# Sets CHANGED_PACKAGE_LIST and CHANGED_PACKAGES -check_changed_packages - -if [[ "${#CHANGED_PACKAGE_LIST[@]}" != 0 ]]; then - plugin_tools publish-check --plugins="${CHANGED_PACKAGES}" -fi diff --git a/script/common.sh b/script/common.sh index 52eeefa6e9ff..0e5290e33d8c 100644 --- a/script/common.sh +++ b/script/common.sh @@ -7,48 +7,6 @@ function error() { echo "$@" 1>&2 } -function get_branch_base_sha() { - local branch_base_sha="$(git merge-base --fork-point FETCH_HEAD HEAD || git merge-base FETCH_HEAD HEAD)" - echo "$branch_base_sha" -} - -function check_changed_packages() { - # Try get a merge base for the branch and calculate affected packages. - # We need this check because some CIs can do a single branch clones with a limited history of commits. - local packages - local branch_base_sha="$(get_branch_base_sha)" - if [[ "$branch_base_sha" != "" ]]; then - echo "Checking for changed packages from $branch_base_sha" - IFS=$'\n' packages=( $(git diff --name-only "$branch_base_sha" HEAD | grep -o "packages/[^/]*" | sed -e "s/packages\///g" | sort | uniq) ) - else - error "Cannot find a merge base for the current branch to run an incremental build..." - error "Please rebase your branch onto the latest master!" - return 1 - fi - - CHANGED_PACKAGES="" - CHANGED_PACKAGE_LIST=() - - # Filter out packages that have been deleted. - for package in "${packages[@]}"; do - if [ -d "$REPO_DIR/packages/$package" ]; then - CHANGED_PACKAGES="${CHANGED_PACKAGES},$package" - CHANGED_PACKAGE_LIST=("${CHANGED_PACKAGE_LIST[@]}" "$package") - fi - done - - if [[ "${#CHANGED_PACKAGE_LIST[@]}" == 0 ]]; then - echo "No changes detected in packages." - else - echo "Detected changes in the following ${#CHANGED_PACKAGE_LIST[@]} package(s):" - for package in "${CHANGED_PACKAGE_LIST[@]}"; do - echo "$package" - done - echo "" - fi - return 0 -} - # Runs the plugin tools from the plugin_tools git submodule. function plugin_tools() { (pushd "$REPO_DIR/script/tool" && dart pub get && popd) >/dev/null diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md index 3a9736c35b47..bc2a775db1f8 100644 --- a/script/tool/CHANGELOG.md +++ b/script/tool/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +- Add `against-pub` flag for version-check, which allows the command to check version with pub. +- Add `machine` flag for publish-check, which replaces outputs to something parsable by machines. + ## 0.1.1 - Update the allowed third-party licenses for flutter/packages. diff --git a/script/tool/lib/src/common.dart b/script/tool/lib/src/common.dart index a63d606b35ca..1e864fcbc38a 100644 --- a/script/tool/lib/src/common.dart +++ b/script/tool/lib/src/common.dart @@ -11,6 +11,7 @@ import 'package:args/command_runner.dart'; import 'package:colorize/colorize.dart'; import 'package:file/file.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:path/path.dart' as p; import 'package:pub_semver/pub_semver.dart'; @@ -563,6 +564,98 @@ class ProcessRunner { } } +/// Finding version of [package] that is published on pub. +class PubVersionFinder { + /// Constructor. + /// + /// Note: you should manually close the [httpClient] when done using the finder. + PubVersionFinder({this.pubHost = defaultPubHost, @required this.httpClient}); + + /// The default pub host to use. + static const String defaultPubHost = 'https://pub.dev'; + + /// The pub host url, defaults to `https://pub.dev`. + final String pubHost; + + /// The http client. + /// + /// You should manually close this client when done using this finder. + final http.Client httpClient; + + /// Get the package version on pub. + Future getPackageVersion( + {@required String package}) async { + assert(package != null && package.isNotEmpty); + final Uri pubHostUri = Uri.parse(pubHost); + final Uri url = pubHostUri.replace(path: '/packages/$package.json'); + final http.Response response = await httpClient.get(url); + + if (response.statusCode == 404) { + return PubVersionFinderResponse( + versions: null, + result: PubVersionFinderResult.noPackageFound, + httpResponse: response); + } else if (response.statusCode != 200) { + return PubVersionFinderResponse( + versions: null, + 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({this.versions, this.result, this.httpResponse}) { + if (versions != null && versions.isNotEmpty) { + versions.sort((Version a, Version b) { + // TODO(cyanglaz): Think about how to handle pre-release version with [Version.prioritize]. + // https://github.com/flutter/flutter/issues/82222 + return b.compareTo(a); + }); + } + } + + /// The versions found in [PubVersionFinder]. + /// + /// This is sorted by largest to smallest, so the first element in the list is the largest version. + /// Might be `null` if the [result] is not [PubVersionFinderResult.success]. + final List versions; + + /// The result of the version finder. + final PubVersionFinderResult result; + + /// The response object of the http request. + final http.Response httpResponse; +} + +/// An enum representing the result of [PubVersionFinder]. +enum PubVersionFinderResult { + /// The version finder successfully found a version. + success, + + /// The version finder failed to find a valid version. + /// + /// This might due to http connection errors or user errors. + fail, + + /// The version finder failed to locate the package. + /// + /// This indicates the package is new. + noPackageFound, +} + /// Finding diffs based on `baseGitDir` and `baseSha`. class GitVersionFinder { /// Constructor diff --git a/script/tool/lib/src/drive_examples_command.dart b/script/tool/lib/src/drive_examples_command.dart index 0230ebd671e1..1572b941078e 100644 --- a/script/tool/lib/src/drive_examples_command.dart +++ b/script/tool/lib/src/drive_examples_command.dart @@ -52,23 +52,37 @@ class DriveExamplesCommand extends PluginCommand { @override Future run() async { final List failingTests = []; + final List pluginsWithoutTests = []; final bool isLinux = argResults[kLinux] == true; final bool isMacos = argResults[kMacos] == true; final bool isWeb = argResults[kWeb] == true; final bool isWindows = argResults[kWindows] == true; await for (final Directory plugin in getPlugins()) { + final String pluginName = plugin.basename; + if (pluginName.endsWith('_platform_interface') && + !plugin.childDirectory('example').existsSync()) { + // Platform interface packages generally aren't intended to have + // examples, and don't need integration tests, so silently skip them + // unless for some reason there is an example directory. + continue; + } + print('\n==========\nChecking $pluginName...'); + if (!(await _pluginSupportedOnCurrentPlatform(plugin, fileSystem))) { + print('Not supported for the target platform; skipping.'); + continue; + } + int examplesFound = 0; + bool testsRan = false; final String flutterCommand = const LocalPlatform().isWindows ? 'flutter.bat' : 'flutter'; for (final Directory example in getExamplesForPlugin(plugin)) { + ++examplesFound; final String packageName = p.relative(example.path, from: packagesDir.path); - if (!(await _pluginSupportedOnCurrentPlatform(plugin, fileSystem))) { - continue; - } final Directory driverTests = fileSystem.directory(p.join(example.path, 'test_driver')); if (!driverTests.existsSync()) { - // No driver tests available for this example + print('No driver tests found for $packageName'); continue; } // Look for driver tests ending in _test.dart in test_driver/ @@ -160,6 +174,7 @@ Tried searching for the following: } for (final String targetPath in targetPaths) { + testsRan = true; final int exitCode = await processRunner.runAndStream( flutterCommand, [ @@ -177,6 +192,11 @@ Tried searching for the following: } } } + if (!testsRan) { + pluginsWithoutTests.add(pluginName); + print( + 'No driver tests run for $pluginName ($examplesFound examples found)'); + } } print('\n\n'); @@ -188,6 +208,15 @@ Tried searching for the following: throw ToolExit(1); } + if (pluginsWithoutTests.isNotEmpty) { + print('The following plugins did not run any integration tests:'); + for (final String plugin in pluginsWithoutTests) { + print(' * $plugin'); + } + print('If this is intentional, they must be explicitly excluded.'); + throw ToolExit(1); + } + print('All driver tests successful!'); } diff --git a/script/tool/lib/src/publish_check_command.dart b/script/tool/lib/src/publish_check_command.dart index 0fb9dbad60a8..84503f4540c6 100644 --- a/script/tool/lib/src/publish_check_command.dart +++ b/script/tool/lib/src/publish_check_command.dart @@ -3,10 +3,14 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:io' as io; import 'package:colorize/colorize.dart'; import 'package:file/file.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'common.dart'; @@ -18,7 +22,10 @@ class PublishCheckCommand extends PluginCommand { Directory packagesDir, FileSystem fileSystem, { ProcessRunner processRunner = const ProcessRunner(), - }) : super(packagesDir, fileSystem, processRunner: processRunner) { + this.httpClient, + }) : _pubVersionFinder = + PubVersionFinder(httpClient: httpClient ?? http.Client()), + super(packagesDir, fileSystem, processRunner: processRunner) { argParser.addFlag( _allowPrereleaseFlag, help: 'Allows the pre-release SDK warning to pass.\n' @@ -26,9 +33,29 @@ class PublishCheckCommand extends PluginCommand { 'the SDK constraint is a pre-release version, is ignored.', defaultsTo: false, ); + argParser.addFlag(_machineFlag, + help: 'Switch outputs to a machine readable JSON. \n' + 'The JSON contains a "status" field indicating the final status of the command, the possible values are:\n' + ' $_statusNeedsPublish: There is at least one package need to be published. They also passed all publish checks.\n' + ' $_statusMessageNoPublish: There are no packages needs to be published. Either no pubspec change detected or all versions have already been published.\n' + ' $_statusMessageError: Some error has occurred.', + defaultsTo: false, + negatable: true); } static const String _allowPrereleaseFlag = 'allow-pre-release'; + static const String _machineFlag = 'machine'; + static const String _statusNeedsPublish = 'needs-publish'; + static const String _statusMessageNoPublish = 'no-publish'; + static const String _statusMessageError = 'error'; + static const String _statusKey = 'status'; + static const String _humanMessageKey = 'humanMessage'; + + final List _validStatus = [ + _statusNeedsPublish, + _statusMessageNoPublish, + _statusMessageError + ]; @override final String name = 'publish-check'; @@ -37,31 +64,74 @@ class PublishCheckCommand extends PluginCommand { final String description = 'Checks to make sure that a plugin *could* be published.'; + /// The custom http client used to query versions on pub. + final http.Client httpClient; + + final PubVersionFinder _pubVersionFinder; + + // The output JSON when the _machineFlag is on. + final Map _machineOutput = {}; + + final List _humanMessages = []; + @override Future run() async { + final ZoneSpecification logSwitchSpecification = ZoneSpecification( + print: (Zone self, ZoneDelegate parent, Zone zone, String message) { + final bool logMachineMessage = argResults[_machineFlag] as bool; + if (logMachineMessage && message != _prettyJson(_machineOutput)) { + _humanMessages.add(message); + } else { + parent.print(zone, message); + } + }); + + await runZoned(_runCommand, zoneSpecification: logSwitchSpecification); + } + + Future _runCommand() async { final List failedPackages = []; + String status = _statusMessageNoPublish; await for (final Directory plugin in getPlugins()) { - if (!(await _passesPublishCheck(plugin))) { - failedPackages.add(plugin); + final _PublishCheckResult result = await _passesPublishCheck(plugin); + switch (result) { + case _PublishCheckResult._notPublished: + if (failedPackages.isEmpty) { + status = _statusNeedsPublish; + } + break; + case _PublishCheckResult._published: + break; + case _PublishCheckResult._error: + failedPackages.add(plugin); + status = _statusMessageError; + break; } } + _pubVersionFinder.httpClient.close(); if (failedPackages.isNotEmpty) { final String error = - 'FAIL: The following ${failedPackages.length} package(s) failed the ' + 'The following ${failedPackages.length} package(s) failed the ' 'publishing check:'; final String joinedFailedPackages = failedPackages.join('\n'); + _printImportantStatusMessage('$error\n$joinedFailedPackages', + isError: true); + } else { + _printImportantStatusMessage('All packages passed publish check!', + isError: false); + } - final Colorize colorizedError = Colorize('$error\n$joinedFailedPackages') - ..red(); - print(colorizedError); - throw ToolExit(1); + if (argResults[_machineFlag] as bool) { + _setStatus(status); + _machineOutput[_humanMessageKey] = _humanMessages; + print(_prettyJson(_machineOutput)); } - final Colorize passedMessage = - Colorize('All packages passed publish check!')..green(); - print(passedMessage); + if (failedPackages.isNotEmpty) { + throw ToolExit(1); + } } Pubspec _tryParsePubspec(Directory package) { @@ -89,8 +159,11 @@ class PublishCheckCommand extends PluginCommand { final Completer stdOutCompleter = Completer(); process.stdout.listen( (List event) { - io.stdout.add(event); - outputBuffer.write(String.fromCharCodes(event)); + final String output = String.fromCharCodes(event); + if (output.isNotEmpty) { + print(output); + outputBuffer.write(output); + } }, onDone: () => stdOutCompleter.complete(), ); @@ -98,8 +171,11 @@ class PublishCheckCommand extends PluginCommand { final Completer stdInCompleter = Completer(); process.stderr.listen( (List event) { - io.stderr.add(event); - outputBuffer.write(String.fromCharCodes(event)); + final String output = String.fromCharCodes(event); + if (output.isNotEmpty) { + _printImportantStatusMessage(output, isError: true); + outputBuffer.write(output); + } }, onDone: () => stdInCompleter.complete(), ); @@ -121,24 +197,97 @@ class PublishCheckCommand extends PluginCommand { 'Packages with an SDK constraint on a pre-release of the Dart SDK should themselves be published as a pre-release version.'); } - Future _passesPublishCheck(Directory package) async { + Future<_PublishCheckResult> _passesPublishCheck(Directory package) async { final String packageName = package.basename; print('Checking that $packageName can be published.'); final Pubspec pubspec = _tryParsePubspec(package); if (pubspec == null) { - return false; + print('no pubspec'); + return _PublishCheckResult._error; } else if (pubspec.publishTo == 'none') { print('Package $packageName is marked as unpublishable. Skipping.'); - return true; + return _PublishCheckResult._published; + } + + final Version version = pubspec.version; + final _PublishCheckResult alreadyPublishedResult = + await _checkIfAlreadyPublished( + packageName: packageName, version: version); + if (alreadyPublishedResult == _PublishCheckResult._published) { + print( + 'Package $packageName version: $version has already be published on pub.'); + return alreadyPublishedResult; + } else if (alreadyPublishedResult == _PublishCheckResult._error) { + print('Check pub version failed $packageName'); + return _PublishCheckResult._error; } if (await _hasValidPublishCheckRun(package)) { print('Package $packageName is able to be published.'); - return true; + return _PublishCheckResult._notPublished; } else { print('Unable to publish $packageName'); - return false; + return _PublishCheckResult._error; + } + } + + // Check if `packageName` already has `version` published on pub. + Future<_PublishCheckResult> _checkIfAlreadyPublished( + {String packageName, Version version}) async { + final PubVersionFinderResponse pubVersionFinderResponse = + await _pubVersionFinder.getPackageVersion(package: packageName); + _PublishCheckResult result; + switch (pubVersionFinderResponse.result) { + case PubVersionFinderResult.success: + result = pubVersionFinderResponse.versions.contains(version) + ? _PublishCheckResult._published + : _PublishCheckResult._notPublished; + break; + case PubVersionFinderResult.fail: + print(''' +Error fetching version on pub for $packageName. +HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} +HTTP response: ${pubVersionFinderResponse.httpResponse.body} +'''); + result = _PublishCheckResult._error; + break; + case PubVersionFinderResult.noPackageFound: + result = _PublishCheckResult._notPublished; + break; } + return result; + } + + void _setStatus(String status) { + assert(_validStatus.contains(status)); + _machineOutput[_statusKey] = status; + } + + String _prettyJson(Map map) { + return const JsonEncoder.withIndent(' ').convert(_machineOutput); } + + void _printImportantStatusMessage(String message, {@required bool isError}) { + final String statusMessage = '${isError ? 'ERROR' : 'SUCCESS'}: $message'; + if (argResults[_machineFlag] as bool) { + print(statusMessage); + } else { + final Colorize colorizedMessage = Colorize(statusMessage); + if (isError) { + colorizedMessage.red(); + } else { + colorizedMessage.green(); + } + print(colorizedMessage); + } + } +} + +enum _PublishCheckResult { + _notPublished, + + _published, + + _error, } diff --git a/script/tool/lib/src/version_check_command.dart b/script/tool/lib/src/version_check_command.dart index db69f4ead865..475caf5d285d 100644 --- a/script/tool/lib/src/version_check_command.dart +++ b/script/tool/lib/src/version_check_command.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; @@ -74,8 +75,21 @@ class VersionCheckCommand extends PluginCommand { FileSystem fileSystem, { ProcessRunner processRunner = const ProcessRunner(), GitDir gitDir, - }) : super(packagesDir, fileSystem, - processRunner: processRunner, gitDir: gitDir); + this.httpClient, + }) : _pubVersionFinder = + PubVersionFinder(httpClient: httpClient ?? http.Client()), + super(packagesDir, fileSystem, + processRunner: processRunner, gitDir: gitDir) { + argParser.addFlag( + _againstPubFlag, + help: 'Whether the version check should run against the version on pub.\n' + 'Defaults to false, which means the version check only run against the previous version in code.', + defaultsTo: false, + negatable: true, + ); + } + + static const String _againstPubFlag = 'against-pub'; @override final String name = 'version-check'; @@ -86,6 +100,11 @@ class VersionCheckCommand extends PluginCommand { 'Also checks if the latest version in CHANGELOG matches the version in pubspec.\n\n' 'This command requires "pub" and "flutter" to be in your path.'; + /// The http client used to query pub server. + final http.Client httpClient; + + final PubVersionFinder _pubVersionFinder; + @override Future run() async { final GitVersionFinder gitVersionFinder = await retrieveVersionFinder(); @@ -115,29 +134,61 @@ class VersionCheckCommand extends PluginCommand { 'intentionally has no version should be marked ' '"publish_to: none".'); } - final Version masterVersion = - await gitVersionFinder.getPackageVersion(pubspecPath); - if (masterVersion == null) { - print('${indentation}Unable to find pubspec in master. ' - 'Safe to ignore if the project is new.'); + Version sourceVersion; + if (argResults[_againstPubFlag] as bool) { + final String packageName = pubspecFile.parent.basename; + final PubVersionFinderResponse pubVersionFinderResponse = + await _pubVersionFinder.getPackageVersion(package: packageName); + switch (pubVersionFinderResponse.result) { + case PubVersionFinderResult.success: + sourceVersion = pubVersionFinderResponse.versions.first; + print( + '$indentation$packageName: Current largest version on pub: $sourceVersion'); + break; + case PubVersionFinderResult.fail: + printErrorAndExit(errorMessage: ''' +${indentation}Error fetching version on pub for $packageName. +${indentation}HTTP Status ${pubVersionFinderResponse.httpResponse.statusCode} +${indentation}HTTP response: ${pubVersionFinderResponse.httpResponse.body} +'''); + break; + case PubVersionFinderResult.noPackageFound: + sourceVersion = null; + break; + } + } else { + sourceVersion = await gitVersionFinder.getPackageVersion(pubspecPath); + } + if (sourceVersion == null) { + String safeToIgnoreMessage; + if (argResults[_againstPubFlag] as bool) { + safeToIgnoreMessage = + '${indentation}Unable to find package on pub server.'; + } else { + safeToIgnoreMessage = + '${indentation}Unable to find pubspec in master.'; + } + print('$safeToIgnoreMessage Safe to ignore if the project is new.'); continue; } - if (masterVersion == headVersion) { + if (sourceVersion == headVersion) { print('${indentation}No version change.'); continue; } final Map allowedNextVersions = - getAllowedNextVersions(masterVersion, headVersion); + getAllowedNextVersions(sourceVersion, headVersion); if (!allowedNextVersions.containsKey(headVersion)) { + final String source = + (argResults[_againstPubFlag] as bool) ? 'pub' : 'master'; final String error = '${indentation}Incorrectly updated version.\n' - '${indentation}HEAD: $headVersion, master: $masterVersion.\n' + '${indentation}HEAD: $headVersion, $source: $sourceVersion.\n' '${indentation}Allowed versions: $allowedNextVersions'; printErrorAndExit(errorMessage: error); } else { - print('$indentation$headVersion -> $masterVersion'); + print('$indentation$headVersion -> $sourceVersion'); } final bool isPlatformInterface = @@ -153,6 +204,7 @@ class VersionCheckCommand extends PluginCommand { await for (final Directory plugin in getPlugins()) { await _checkVersionsMatch(plugin); } + _pubVersionFinder.httpClient.close(); print('No version check errors found!'); } @@ -224,7 +276,7 @@ The first version listed in CHANGELOG.md is $fromChangeLog. printErrorAndExit(errorMessage: ''' When bumping the version for release, the NEXT section should be incorporated into the new version's release notes. - '''); +'''); } } diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart index 3ae46ffc15d9..d6ac449e7fd3 100644 --- a/script/tool/test/common_test.dart +++ b/script/tool/test/common_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; @@ -9,7 +10,10 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; +import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; import 'util.dart'; @@ -334,6 +338,80 @@ file2/file2.cc .runCommand(['diff', '--name-only', customBaseSha, 'HEAD'])); }); }); + + group('$PubVersionFinder', () { + test('Package does not exist.', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 404); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, isNull); + expect(response.result, PubVersionFinderResult.noPackageFound); + expect(response.httpResponse.statusCode, 404); + expect(response.httpResponse.body, ''); + }); + + test('HTTP error when getting versions from pub', () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('', 400); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, isNull); + expect(response.result, PubVersionFinderResult.fail); + expect(response.httpResponse.statusCode, 400); + expect(response.httpResponse.body, ''); + }); + + test('Get a correct list of versions when http response is OK.', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + '0.0.2+2', + '0.1.1', + '0.0.1+1', + '0.1.0', + '0.2.0', + '0.1.0+1', + '0.0.2+1', + '2.0.0', + '1.2.0', + '1.0.0', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final PubVersionFinder finder = PubVersionFinder(httpClient: mockClient); + final PubVersionFinderResponse response = + await finder.getPackageVersion(package: 'some_package'); + + expect(response.versions, [ + Version.parse('2.0.0'), + Version.parse('1.2.0'), + Version.parse('1.0.0'), + Version.parse('0.2.0'), + Version.parse('0.1.1'), + Version.parse('0.1.0+1'), + Version.parse('0.1.0'), + Version.parse('0.0.2+2'), + Version.parse('0.0.2+1'), + Version.parse('0.0.2'), + Version.parse('0.0.1+1'), + Version.parse('0.0.1'), + ]); + expect(response.result, PubVersionFinderResult.success); + expect(response.httpResponse.statusCode, 200); + expect(response.httpResponse.body, json.encode(httpResponse)); + }); + }); } class SamplePluginCommand extends PluginCommand { diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart index c5960b2c342e..5ba8d8af25ff 100644 --- a/script/tool/test/drive_examples_command_test.dart +++ b/script/tool/test/drive_examples_command_test.dart @@ -55,6 +55,7 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', '\n\n', 'All driver tests successful!', ]), @@ -100,6 +101,7 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', '\n\n', 'All driver tests successful!', ]), @@ -143,6 +145,25 @@ void main() { throwsA(const TypeMatcher())); }); + test('a plugin without any integration test files is reported as an error', + () async { + createFakePlugin('plugin', + withExtraFiles: >[ + ['example', 'lib', 'main.dart'], + ], + isAndroidPlugin: true, + isIosPlugin: true); + + final Directory pluginExampleDirectory = + mockPackagesDir.childDirectory('plugin').childDirectory('example'); + + createFakePubspec(pluginExampleDirectory, isFlutter: true); + + await expectLater( + () => runCapturingPrint(runner, ['drive-examples']), + throwsA(const TypeMatcher())); + }); + test( 'driving under folder "test_driver" when targets are under "integration_test"', () async { @@ -168,6 +189,7 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', '\n\n', 'All driver tests successful!', ]), @@ -223,6 +245,8 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', + 'Not supported for the target platform; skipping.', '\n\n', 'All driver tests successful!', ]), @@ -255,6 +279,7 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', '\n\n', 'All driver tests successful!', ]), @@ -300,6 +325,8 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', + 'Not supported for the target platform; skipping.', '\n\n', 'All driver tests successful!', ]), @@ -332,6 +359,7 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', '\n\n', 'All driver tests successful!', ]), @@ -379,6 +407,8 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', + 'Not supported for the target platform; skipping.', '\n\n', 'All driver tests successful!', ]), @@ -411,6 +441,7 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', '\n\n', 'All driver tests successful!', ]), @@ -460,6 +491,8 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', + 'Not supported for the target platform; skipping.', '\n\n', 'All driver tests successful!', ]), @@ -492,6 +525,7 @@ void main() { expect( output, orderedEquals([ + '\n==========\nChecking plugin...', '\n\n', 'All driver tests successful!', ]), @@ -535,6 +569,29 @@ void main() { 'drive-examples', ]); + expect( + output, + orderedEquals([ + '\n==========\nChecking plugin...', + 'Not supported for the target platform; skipping.', + '\n\n', + 'All driver tests successful!', + ]), + ); + + print(processRunner.recordedCalls); + // Output should be empty since running drive-examples --macos with no macos + // implementation is a no-op. + expect(processRunner.recordedCalls, []); + }); + + test('platform interface plugins are silently skipped', () async { + createFakePlugin('aplugin_platform_interface'); + + final List output = await runCapturingPrint(runner, [ + 'drive-examples', + ]); + expect( output, orderedEquals([ diff --git a/script/tool/test/publish_check_command_test.dart b/script/tool/test/publish_check_command_test.dart index 0a9d36f2ea6f..eca7caf53403 100644 --- a/script/tool/test/publish_check_command_test.dart +++ b/script/tool/test/publish_check_command_test.dart @@ -3,12 +3,15 @@ // found in the LICENSE file. import 'dart:collection'; +import 'dart:convert'; import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/publish_check_command.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:test/test.dart'; import 'mocks.dart'; @@ -126,6 +129,222 @@ void main() { expect(runner.run(['publish-check']), throwsA(isA())); }); + + test( + '--machine: Log JSON with status:no-publish and correct human message, if there are no packages need to be published. ', + () async { + const Map httpResponseA = { + 'name': 'a', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + const Map httpResponseB = { + 'name': 'b', + 'versions': [ + '0.0.1', + '0.1.0', + '0.2.0', + ], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'no_publish_a.json') { + return http.Response(json.encode(httpResponseA), 200); + } else if (request.url.pathSegments.last == 'no_publish_b.json') { + return http.Response(json.encode(httpResponseB), 200); + } + return null; + }); + final PublishCheckCommand command = PublishCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, httpClient: mockClient); + + runner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + runner.addCommand(command); + + final Directory plugin1Dir = + createFakePlugin('no_publish_a', includeVersion: true); + final Directory plugin2Dir = + createFakePlugin('no_publish_b', includeVersion: true); + + createFakePubspec(plugin1Dir, + name: 'no_publish_a', includeVersion: true, version: '0.1.0'); + createFakePubspec(plugin2Dir, + name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + + processRunner.processesToReturn.add( + MockProcess()..exitCodeCompleter.complete(0), + ); + final List output = await runCapturingPrint( + runner, ['publish-check', '--machine']); + + // ignore: use_raw_strings + expect(output.first, ''' +{ + "status": "no-publish", + "humanMessage": [ + "Checking that no_publish_a can be published.", + "Package no_publish_a version: 0.1.0 has already be published on pub.", + "Checking that no_publish_b can be published.", + "Package no_publish_b version: 0.2.0 has already be published on pub.", + "SUCCESS: All packages passed publish check!" + ] +}'''); + }); + + test( + '--machine: Log JSON with status:needs-publish and correct human message, if there is at least 1 plugin needs to be published.', + () async { + const Map httpResponseA = { + 'name': 'a', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + const Map httpResponseB = { + 'name': 'b', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + if (request.url.pathSegments.last == 'no_publish_a.json') { + return http.Response(json.encode(httpResponseA), 200); + } else if (request.url.pathSegments.last == 'no_publish_b.json') { + return http.Response(json.encode(httpResponseB), 200); + } + return null; + }); + final PublishCheckCommand command = PublishCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, httpClient: mockClient); + + runner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + runner.addCommand(command); + + final Directory plugin1Dir = + createFakePlugin('no_publish_a', includeVersion: true); + final Directory plugin2Dir = + createFakePlugin('no_publish_b', includeVersion: true); + + createFakePubspec(plugin1Dir, + name: 'no_publish_a', includeVersion: true, version: '0.1.0'); + createFakePubspec(plugin2Dir, + name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + + processRunner.processesToReturn.add( + MockProcess()..exitCodeCompleter.complete(0), + ); + + final List output = await runCapturingPrint( + runner, ['publish-check', '--machine']); + + // ignore: use_raw_strings + expect(output.first, ''' +{ + "status": "needs-publish", + "humanMessage": [ + "Checking that no_publish_a can be published.", + "Package no_publish_a version: 0.1.0 has already be published on pub.", + "Checking that no_publish_b can be published.", + "Package no_publish_b is able to be published.", + "SUCCESS: All packages passed publish check!" + ] +}'''); + }); + + test( + '--machine: Log correct JSON, if there is at least 1 plugin contains error.', + () async { + const Map httpResponseA = { + 'name': 'a', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + const Map httpResponseB = { + 'name': 'b', + 'versions': [ + '0.0.1', + '0.1.0', + ], + }; + + final MockClient mockClient = MockClient((http.Request request) async { + print('url ${request.url}'); + print(request.url.pathSegments.last); + if (request.url.pathSegments.last == 'no_publish_a.json') { + return http.Response(json.encode(httpResponseA), 200); + } else if (request.url.pathSegments.last == 'no_publish_b.json') { + return http.Response(json.encode(httpResponseB), 200); + } + return null; + }); + final PublishCheckCommand command = PublishCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, httpClient: mockClient); + + runner = CommandRunner( + 'publish_check_command', + 'Test for publish-check command.', + ); + runner.addCommand(command); + + final Directory plugin1Dir = + createFakePlugin('no_publish_a', includeVersion: true); + final Directory plugin2Dir = + createFakePlugin('no_publish_b', includeVersion: true); + + createFakePubspec(plugin1Dir, + name: 'no_publish_a', includeVersion: true, version: '0.1.0'); + createFakePubspec(plugin2Dir, + name: 'no_publish_b', includeVersion: true, version: '0.2.0'); + await plugin1Dir.childFile('pubspec.yaml').writeAsString('bad-yaml'); + + processRunner.processesToReturn.add( + MockProcess()..exitCodeCompleter.complete(0), + ); + + bool hasError = false; + final List output = await runCapturingPrint( + runner, ['publish-check', '--machine'], + errorHandler: (Error error) { + expect(error, isA()); + hasError = true; + }); + expect(hasError, isTrue); + + // ignore: use_raw_strings + expect(output.first, ''' +{ + "status": "error", + "humanMessage": [ + "Checking that no_publish_a can be published.", + "Failed to parse `pubspec.yaml` at /packages/no_publish_a/pubspec.yaml: ParsedYamlException: line 1, column 1: Not a map\\n ╷\\n1 │ bad-yaml\\n │ ^^^^^^^^\\n ╵}", + "no pubspec", + "Checking that no_publish_b can be published.", + "url https://pub.dev/packages/no_publish_b.json", + "no_publish_b.json", + "Package no_publish_b is able to be published.", + "ERROR: The following 1 package(s) failed the publishing check:\\nMemoryDirectory: '/packages/no_publish_a'" + ] +}'''); + }); }); } diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart index 25b248b00af5..d708dc71bef7 100644 --- a/script/tool/test/util.dart +++ b/script/tool/test/util.dart @@ -205,19 +205,29 @@ void cleanupPackages() { }); } +typedef _ErrorHandler = void Function(Error error); + /// Run the command [runner] with the given [args] and return /// what was printed. +/// A custom [errorHandler] can be used to handle the runner error as desired without throwing. Future> runCapturingPrint( - CommandRunner runner, List args) async { + CommandRunner runner, List args, {_ErrorHandler errorHandler}) async { final List prints = []; final ZoneSpecification spec = ZoneSpecification( print: (_, __, ___, String message) { prints.add(message); }, ); - await Zone.current + try { + await Zone.current .fork(specification: spec) .run>(() => runner.run(args)); + } on Error catch (e) { + if (errorHandler == null) { + rethrow; + } + errorHandler(e); + } return prints; } diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart index ea1a82ae7445..d67103f716a1 100644 --- a/script/tool/test/version_check_test.dart +++ b/script/tool/test/version_check_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'dart:io' as io; import 'package:args/command_runner.dart'; @@ -10,6 +11,8 @@ import 'package:file/file.dart'; import 'package:flutter_plugin_tools/src/common.dart'; import 'package:flutter_plugin_tools/src/version_check_command.dart'; import 'package:git/git.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; @@ -39,19 +42,29 @@ class MockGitDir extends Mock implements GitDir {} class MockProcessResult extends Mock implements io.ProcessResult {} +const String _redColorMessagePrefix = '\x1B[31m'; +const String _redColorMessagePostfix = '\x1B[0m'; + +// Some error message was printed in a "Colorized" red message. So `\x1B[31m` and `\x1B[0m` needs to be included. +String _redColorString(String string) { + return '$_redColorMessagePrefix$string$_redColorMessagePostfix'; +} + void main() { + const String indentation = ' '; group('$VersionCheckCommand', () { CommandRunner runner; RecordingProcessRunner processRunner; List> gitDirCommands; String gitDiffResponse; Map gitShowResponses; + MockGitDir gitDir; setUp(() { gitDirCommands = >[]; gitDiffResponse = ''; gitShowResponses = {}; - final MockGitDir gitDir = MockGitDir(); + gitDir = MockGitDir(); when(gitDir.runCommand(any)).thenAnswer((Invocation invocation) { gitDirCommands.add(invocation.positionalArguments[0] as List); final MockProcessResult mockProcessResult = MockProcessResult(); @@ -165,6 +178,7 @@ void main() { expect( output, containsAllInOrder([ + '${indentation}Unable to find pubspec in master. Safe to ignore if the project is new.', 'No version check errors found!', ]), ); @@ -321,25 +335,27 @@ void main() { * Some changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + 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, - throwsA(const TypeMatcher()), + containsAllInOrder([ + _redColorString(''' +versions for plugin in CHANGELOG.md and pubspec.yaml do not match. +The version in pubspec.yaml is 1.0.1. +The first version listed in CHANGELOG.md is 1.0.2. +'''), + ]), ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ - ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.1. - The first version listed in CHANGELOG.md is 1.0.2. - ''', - ]), - ); - } on ToolExit catch (_) {} }); test('Success if CHANGELOG and pubspec versions match', () async { @@ -388,25 +404,29 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + 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, - throwsA(const TypeMatcher()), - ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ + containsAllInOrder([ + _redColorString( ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.0. - The first version listed in CHANGELOG.md is 1.0.1. - ''', - ]), - ); - } on ToolExit catch (_) {} +versions for plugin in CHANGELOG.md and pubspec.yaml do not match. +The version in pubspec.yaml is 1.0.0. +The first version listed in CHANGELOG.md is 1.0.1. +''', + ) + ]), + ); }); test('Allow NEXT as a placeholder for gathering CHANGELOG entries', @@ -463,25 +483,28 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + 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, - throwsA(const TypeMatcher()), - ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ + containsAllInOrder([ + _redColorString( ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.0. - The first version listed in CHANGELOG.md is 1.0.1. - ''', - ]), - ); - } on ToolExit catch (_) {} +When bumping the version for release, the NEXT section should be incorporated +into the new version's release notes. +''', + ) + ]), + ); }); test('Fail if the version changes without replacing NEXT', () async { @@ -502,25 +525,194 @@ void main() { * Some other changes. '''; createFakeCHANGELOG(pluginDirectory, changelog); - final Future> output = runCapturingPrint( - runner, ['version-check', '--base-sha=master']); - await expectLater( + 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, - throwsA(const TypeMatcher()), + containsAllInOrder([ + 'Found NEXT; validating next version in the CHANGELOG.', + _redColorString( + ''' +versions for plugin in CHANGELOG.md and pubspec.yaml do not match. +The version in pubspec.yaml is 1.0.1. +The first version listed in CHANGELOG.md is 1.0.0. +''', + ) + ]), + ); + }); + + test('allows valid against pub', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + '1.0.0', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + }; + final List output = await runCapturingPrint(runner, + ['version-check', '--base-sha=master', '--against-pub']); + + expect( + output, + containsAllInOrder([ + '${indentation}plugin: Current largest version on pub: 1.0.0', + 'No version check errors found!', + ]), ); - try { - final List outputValue = await output; - await expectLater( - outputValue, - containsAllInOrder([ + }); + + test('denies invalid against pub', () async { + const Map httpResponse = { + 'name': 'some_package', + 'versions': [ + '0.0.1', + '0.0.2', + ], + }; + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response(json.encode(httpResponse), 200); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.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([ + _redColorString( + ''' +${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 { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('xx', 400); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.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([ + _redColorString( ''' - versions for plugin in CHANGELOG.md and pubspec.yaml do not match. - The version in pubspec.yaml is 1.0.0. - The first version listed in CHANGELOG.md is 1.0.1. - ''', - ]), - ); - } on ToolExit catch (_) {} +${indentation}Error fetching version on pub for plugin. +${indentation}HTTP Status 400 +${indentation}HTTP response: xx +''', + ) + ]), + ); + }); + + test('when checking against pub, allow any version if http status is 404.', + () async { + final MockClient mockClient = MockClient((http.Request request) async { + return http.Response('xx', 404); + }); + final VersionCheckCommand command = VersionCheckCommand( + mockPackagesDir, mockFileSystem, + processRunner: processRunner, gitDir: gitDir, httpClient: mockClient); + + runner = CommandRunner( + 'version_check_command', 'Test for $VersionCheckCommand'); + runner.addCommand(command); + + createFakePlugin('plugin', includeChangeLog: true, includeVersion: true); + gitDiffResponse = 'packages/plugin/pubspec.yaml'; + gitShowResponses = { + 'master:packages/plugin/pubspec.yaml': 'version: 1.0.0', + 'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0', + }; + final List result = await runCapturingPrint(runner, + ['version-check', '--base-sha=master', '--against-pub']); + + expect( + result, + containsAllInOrder([ + '${indentation}Unable to find package on pub server. Safe to ignore if the project is new.', + 'No version check errors found!', + ]), + ); }); }); diff --git a/script/incremental_build.sh b/script/tool_runner.sh similarity index 100% rename from script/incremental_build.sh rename to script/tool_runner.sh