diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a6177f6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +## Project + +Flutter plugin for in-app updates. iOS checks via iTunes Lookup API (pure Dart), shows App Store page via StoreKit. Android uses Play Core API (Kotlin native). + +## Structure + +``` +lib/ + in_app_update_flutter.dart # Public API, delegates to method channel + src/ + method_channel/ + in_app_update_flutter_method_channel.dart # Native method channel calls + models/ + models.dart # Barrel export + app_update_info.dart # Unified cross-platform result + app_update_info_ios.dart # iOS-specific result + app_update_info_android.dart # Android-specific result (pre-existing) + update_config.dart # UpdateConfig, AndroidUpdateType enum + update_availability_android.dart # Android availability enum (pre-existing) + update_result_android.dart # Android result enum (pre-existing) + install_state_android.dart # Android install state (pre-existing) + install_status_android.dart # Android install status enum (pre-existing) + ios_update_check.dart # Pure Dart iTunes Lookup API call +android/ # Kotlin native (Play Core) +ios/ # Swift native (StoreKit presentation only) +test/ # Unit tests +``` + +## Key patterns + +- `InAppUpdateFlutter` is the public entry point, takes optional `UpdateConfig` +- All methods delegate to `MethodChannelInAppUpdateFlutter` +- iOS update checking is pure Dart (`ios_update_check.dart`) — no native code +- iOS native code (`InAppUpdateFlutterPlugin.swift`) only handles `showStoreUpdateIos` (StoreKit) +- Android native code handles all Play Core methods +- `UpdateConfig` holds defaults: `appStoreId`, `iosAppStoreRegion`, `androidUpdateType` + +## Commands + +```bash +dart format lib/ +flutter analyze +flutter test +``` + +Always run `dart format lib/` before committing. + +## Dependencies + +- `package_info_plus` — get installed version and bundle ID (used by iOS check) +- `pub_semver` — semantic version comparison (used by iOS check) +- No `plugin_platform_interface` — was removed, not needed + +## Conventions + +- Models use `const` constructors and factory methods +- Method channel methods are `snake_case` strings matching native side +- Deprecation via `@Deprecated` annotation, not removal +- Cross-platform methods (`checkUpdate`, `startUpdate`, `checkAndUpdate`) route via `Platform.isIOS`/`Platform.isAndroid` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4a73cfb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +Read AGENTS.md diff --git a/README.md b/README.md index 02e8982..d46207d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Flutter plugin for in-app updates on both iOS and Android. -On **iOS**, it presents the App Store product page using `SKStoreProductViewController` (StoreKit), keeping users inside the app during the update flow. On **Android**, it integrates with Google Play's In-App Updates API to support both immediate (blocking) and flexible (background) update flows. +On **iOS**, it checks for updates via the iTunes Lookup API and presents the App Store product page using `SKStoreProductViewController` (StoreKit), keeping users inside the app during the update flow. On **Android**, it integrates with Google Play's In-App Updates API to support both immediate (blocking) and flexible (background) update flows. --- @@ -16,14 +16,15 @@ On **iOS**, it presents the App Store product page using `SKStoreProductViewCont ## Features -- iOS: Show the App Store update prompt using `SKStoreProductViewController` without navigating users away from the app -- iOS: Native Swift implementation with zero AppDelegate configuration required -- iOS: Supports both Swift Package Manager (SPM) and CocoaPods -- Android: Check update availability and metadata via the Play Core API -- Android: Immediate update flow — full-screen, blocking prompt the user must accept -- Android: Flexible update flow — background download while the user continues using the app -- Android: Install state stream for monitoring flexible update download progress -- Works on Flutter with a simple, unified API +- **iOS**: Check for updates via iTunes Lookup API (pure Dart, no native code) +- **iOS**: Show the App Store update prompt using `SKStoreProductViewController` without navigating users away from the app +- **iOS**: Native Swift implementation with zero AppDelegate configuration required +- **iOS**: Supports both Swift Package Manager (SPM) and CocoaPods +- **Android**: Check update availability and metadata via the Play Core API +- **Android**: Immediate update flow — full-screen, blocking prompt the user must accept +- **Android**: Flexible update flow — background download while the user continues using the app +- **Android**: Install state stream for monitoring flexible update download progress +- **Cross-platform**: Unified `checkUpdate()`, `startUpdate()`, and `checkAndUpdate()` APIs --- @@ -44,16 +45,89 @@ flutter pub get --- -## iOS Usage +## Setup -Pass your numeric App Store ID to `showUpdateForIos`. The ID can be found in your App Store Connect URL or the app's public App Store link. +Create an `InAppUpdateFlutter` instance with an `UpdateConfig` to set your app's defaults: + +### Both iOS and Android ```dart import 'package:in_app_update_flutter/in_app_update_flutter.dart'; -await InAppUpdateFlutter().showUpdateForIos(appStoreId: '1234567890'); +final updater = InAppUpdateFlutter(UpdateConfig( + appStoreId: '1234567890', // Required for iOS + iosAppStoreRegion: 'us', // Optional: specific App Store region + androidUpdateType: AndroidUpdateType.flexible, // Optional: immediate (default) or flexible +)); +``` + +### iOS only + +```dart +final updater = InAppUpdateFlutter(UpdateConfig( + appStoreId: '1234567890', +)); +``` + +### Android only + +```dart +final updater = InAppUpdateFlutter(UpdateConfig( + androidUpdateType: AndroidUpdateType.flexible, // or .immediate (default) +)); +``` + +--- + +## Quick Start + +```dart +await updater.checkAndUpdate(); +``` + +If an update is available, it starts the flow automatically using your config defaults. + +--- + +## Usage + +### Check for updates + +```dart +final info = await updater.checkUpdate(); + +if (info.updateAvailable) { + print('Update available: ${info.storeVersion}'); +} +``` + +### Start the update flow + +```dart +// On iOS: presents App Store page via StoreKit +// On Android: starts immediate (blocking) update +await updater.startUpdate(); +``` + +### Per-call overrides + +Config values can be overridden on any call: + +```dart +await updater.checkUpdate(iosAppStoreRegion: 'gb'); +await updater.startUpdate(appStoreId: '9876543210'); +await updater.checkAndUpdate( + iosAppStoreRegion: 'jp', + appStoreId: '9876543210', +); ``` +--- + +## iOS Details + +Pass your numeric App Store ID to `showUpdateForIos` (or set it in `UpdateConfig`). The ID can be found in your App Store Connect URL or the app's public App Store link. + **How to find your App Store ID:** 1. Open your app's App Store URL — for example: `https://apps.apple.com/app/id1234567890` @@ -66,7 +140,7 @@ await InAppUpdateFlutter().showUpdateForIos(appStoreId: '1234567890'); --- -## Android Usage +## Android Details Android uses Google Play's In-App Updates API. The typical flow is: @@ -78,15 +152,11 @@ Android uses Google Play's In-App Updates API. The typical flow is: An immediate update presents a full-screen prompt that the user must complete before continuing. Use this for critical updates. ```dart -import 'package:in_app_update_flutter/in_app_update_flutter.dart'; - -final plugin = InAppUpdateFlutter(); - -final info = await plugin.checkUpdateAndroid(); +final info = await updater.checkUpdateAndroid(); if (info.updateAvailability == UpdateAvailabilityAndroid.updateAvailable && info.isImmediateUpdateAllowed) { - final result = await plugin.startImmediateUpdateAndroid(); + final result = await updater.startImmediateUpdateAndroid(); // result is UpdateResultAndroid.success or UpdateResultAndroid.userCanceled } ``` @@ -96,19 +166,15 @@ if (info.updateAvailability == UpdateAvailabilityAndroid.updateAvailable && A flexible update downloads in the background while the user continues using the app. When the download completes, call `completeUpdateAndroid()` to apply the update. ```dart -import 'package:in_app_update_flutter/in_app_update_flutter.dart'; - -final plugin = InAppUpdateFlutter(); - -final info = await plugin.checkUpdateAndroid(); +final info = await updater.checkUpdateAndroid(); if (info.updateAvailability == UpdateAvailabilityAndroid.updateAvailable && info.isFlexibleUpdateAllowed) { - await plugin.startFlexibleUpdateAndroid(); + await updater.startFlexibleUpdateAndroid(); - plugin.installStateStreamAndroid.listen((state) { + updater.installStateStreamAndroid.listen((state) { if (state.installStatus == InstallStatusAndroid.downloaded) { - plugin.completeUpdateAndroid(); + updater.completeUpdateAndroid(); } }); } diff --git a/example/pubspec.lock b/example/pubspec.lock index 423f0dd..ec9c1e5 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.1" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" fake_async: dependency: transitive description: @@ -57,14 +57,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -88,18 +104,39 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter source: sdk version: "0.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" in_app_update_flutter: dependency: "direct main" description: path: ".." relative: true source: path - version: "2.0.0" + version: "2.0.3" integration_test: dependency: "direct dev" description: flutter @@ -157,10 +194,26 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" + url: "https://pub.dev" + source: hosted + version: "10.1.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: @@ -173,10 +226,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -189,10 +242,18 @@ packages: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "2.2.0" sky_engine: dependency: transitive description: flutter @@ -202,10 +263,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -226,10 +287,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" sync_http: dependency: transitive description: @@ -242,18 +303,26 @@ packages: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -266,18 +335,34 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" webdriver: dependency: transitive description: name: webdriver - sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "6.3.0" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/lib/in_app_update_flutter.dart b/lib/in_app_update_flutter.dart index 9ffdb14..39b06ec 100644 --- a/lib/in_app_update_flutter.dart +++ b/lib/in_app_update_flutter.dart @@ -1,91 +1,125 @@ +import 'package:in_app_update_flutter/src/method_channel/in_app_update_flutter_method_channel.dart'; import 'package:in_app_update_flutter/src/models/models.dart'; -import 'package:in_app_update_flutter/src/platform_interface/in_app_update_flutter_platform_interface.dart'; export 'package:in_app_update_flutter/src/models/models.dart'; /// A Flutter plugin for in-app updates. /// -/// On iOS, use [showUpdateForIos] to present the App Store product page -/// using StoreKit. +/// Create an instance with [UpdateConfig] to set defaults, then call +/// [checkUpdate], [startUpdate], or [checkAndUpdate]. /// -/// On Android, use [checkUpdateAndroid] to check for updates via Google Play's -/// In-App Updates API, then [startImmediateUpdateAndroid] or -/// [startFlexibleUpdateAndroid] to start the update flow. +/// ```dart +/// final updater = InAppUpdateFlutter(UpdateConfig( +/// appStoreId: '123456789', +/// iosAppStoreRegion: 'us', +/// )); +/// +/// // Simple: check and update in one call +/// final info = await updater.checkAndUpdate(); +/// +/// // Or step by step +/// final info = await updater.checkUpdate(); +/// if (info.updateAvailable) await updater.startUpdate(); +/// ``` class InAppUpdateFlutter { - /// Shows the platform-specific in-app update UI. + final _impl = MethodChannelInAppUpdateFlutter(); + final UpdateConfig _config; + + /// Creates an [InAppUpdateFlutter] instance. + /// + /// If [config] is provided, its values are used as defaults for all + /// operations. Per-call parameters always override config values. + InAppUpdateFlutter([this._config = const UpdateConfig()]); + + /// Cross-platform: Checks whether an update is available. + /// + /// On iOS, queries the iTunes Lookup API. On Android, uses Play Core. + /// Returns a unified [AppUpdateInfo] with platform-specific details. + /// + /// [iosAppStoreRegion] overrides [UpdateConfig.iosAppStoreRegion] if provided. + Future checkUpdate({String? iosAppStoreRegion}) => + _impl.checkUpdate( + iosAppStoreRegion: iosAppStoreRegion ?? _config.iosAppStoreRegion, + ); + + /// Cross-platform: Starts the update flow. + /// + /// On iOS, presents the App Store product page via StoreKit. + /// On Android, starts the update flow using [UpdateConfig.androidUpdateType] + /// (default: immediate). + /// + /// [appStoreId] overrides [UpdateConfig.appStoreId] if provided. + Future startUpdate( + {String? appStoreId, AndroidUpdateType? androidUpdateType}) => + _impl.startUpdate( + appStoreId: appStoreId ?? _config.appStoreId, + androidUpdateType: androidUpdateType ?? _config.androidUpdateType, + ); + + /// Cross-platform: Checks for an update and starts the flow if available. /// - /// [appStoreId] is the numeric App Store ID of your app - /// (found in your App Store Connect URL). + /// Convenience method that combines [checkUpdate] and [startUpdate]. + /// On iOS, [appStoreId] (or [UpdateConfig.appStoreId]) is required. + /// On Android, uses [UpdateConfig.androidUpdateType] by default. + Future checkAndUpdate({ + String? iosAppStoreRegion, + String? appStoreId, + AndroidUpdateType? androidUpdateType, + }) => + _impl.checkAndUpdate( + iosAppStoreRegion: iosAppStoreRegion ?? _config.iosAppStoreRegion, + appStoreId: appStoreId ?? _config.appStoreId, + androidUpdateType: androidUpdateType ?? _config.androidUpdateType, + ); + + /// Shows the platform-specific in-app update UI. @Deprecated( 'Use showUpdateForIos() on iOS or checkUpdateAndroid() + ' 'startImmediateUpdateAndroid()/startFlexibleUpdateAndroid() on Android', ) - Future showUpdate({required String appStoreId}) { - // ignore: deprecated_member_use_from_same_package - return InAppUpdateFlutterPlatform.instance - .showUpdate(appStoreId: appStoreId); - } + Future showUpdate({required String appStoreId}) => + // ignore: deprecated_member_use_from_same_package + _impl.showUpdate(appStoreId: appStoreId); /// iOS: Shows the App Store product page overlay via StoreKit. /// - /// [appStoreId] is the numeric App Store ID of your app - /// (found in your App Store Connect URL). - Future showUpdateForIos({required String appStoreId}) { - return InAppUpdateFlutterPlatform.instance - .showUpdateForIos(appStoreId: appStoreId); - } + /// [appStoreId] overrides [UpdateConfig.appStoreId] if provided. + Future showUpdateForIos({String? appStoreId}) => _impl.showUpdateForIos( + appStoreId: appStoreId ?? _config.appStoreId ?? '', + ); - /// Android: Checks whether an in-app update is available via Play Core. + /// iOS: Checks whether an update is available via the iTunes Lookup API. /// - /// Returns an [AppUpdateInfoAndroid] containing update metadata such as - /// availability, version code, priority, staleness, and allowed update types. - Future checkUpdateAndroid() { - return InAppUpdateFlutterPlatform.instance.checkUpdateAndroid(); - } + /// [iosAppStoreRegion] overrides [UpdateConfig.iosAppStoreRegion] if provided. + Future checkUpdateIos({String? iosAppStoreRegion}) => + _impl.checkUpdateIos( + iosAppStoreRegion: iosAppStoreRegion ?? _config.iosAppStoreRegion, + ); + + /// Android: Checks whether an in-app update is available via Play Core. + Future checkUpdateAndroid() => + _impl.checkUpdateAndroid(); /// Android: Starts the immediate (full-screen, blocking) update flow. - /// - /// The user must accept the update to continue using the app. If the user - /// closes the update screen, [UpdateResultAndroid.userCanceled] is returned. - /// - /// If [allowAssetPackDeletion] is `true`, the system may delete asset packs - /// to free up storage for the update. Future startImmediateUpdateAndroid({ bool allowAssetPackDeletion = false, - }) { - return InAppUpdateFlutterPlatform.instance.startImmediateUpdateAndroid( - allowAssetPackDeletion: allowAssetPackDeletion, - ); - } + }) => + _impl.startImmediateUpdateAndroid( + allowAssetPackDeletion: allowAssetPackDeletion, + ); /// Android: Starts the flexible (background download) update flow. - /// - /// The update downloads in the background while the user continues - /// using the app. Listen to [installStateStreamAndroid] for download - /// progress, and call [completeUpdateAndroid] when the download is complete. - /// - /// If [allowAssetPackDeletion] is `true`, the system may delete asset packs - /// to free up storage for the update. Future startFlexibleUpdateAndroid({ bool allowAssetPackDeletion = false, - }) { - return InAppUpdateFlutterPlatform.instance.startFlexibleUpdateAndroid( - allowAssetPackDeletion: allowAssetPackDeletion, - ); - } + }) => + _impl.startFlexibleUpdateAndroid( + allowAssetPackDeletion: allowAssetPackDeletion, + ); /// Android: Completes a flexible update by triggering an app restart. - /// - /// Call this after [installStateStreamAndroid] reports - /// [InstallStatusAndroid.downloaded]. - Future completeUpdateAndroid() { - return InAppUpdateFlutterPlatform.instance.completeUpdateAndroid(); - } + Future completeUpdateAndroid() => _impl.completeUpdateAndroid(); /// Android: A stream of install state changes during a flexible update. - /// - /// Emits [InstallStateAndroid] events with download progress and status. - Stream get installStateStreamAndroid { - return InAppUpdateFlutterPlatform.instance.installStateStreamAndroid; - } + Stream get installStateStreamAndroid => + _impl.installStateStreamAndroid; } diff --git a/lib/src/ios_update_check.dart b/lib/src/ios_update_check.dart new file mode 100644 index 0000000..8db013f --- /dev/null +++ b/lib/src/ios_update_check.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'package:in_app_update_flutter/src/models/models.dart'; + +/// Checks for iOS App Store updates using the iTunes Lookup API. +/// Pure Dart — no native code or method channels required. +Future checkUpdateIosImpl({String? iosAppStoreRegion}) async { + final packageInfo = await PackageInfo.fromPlatform(); + final bundleId = packageInfo.packageName; + final installedVersion = packageInfo.version; + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final region = iosAppStoreRegion != null ? '$iosAppStoreRegion/' : ''; + final uri = Uri.parse( + 'https://itunes.apple.com/${region}lookup?bundleId=$bundleId&_=$timestamp', + ); + + final client = HttpClient(); + try { + final request = await client.getUrl(uri); + final response = await request.close(); + final responseBody = await response.transform(utf8.decoder).join(); + + final storeVersion = + RegExp('"version":\\s*"([^"]*)"').firstMatch(responseBody)?.group(1); + + if (storeVersion == null) { + return AppUpdateInfoIos( + storeVersion: '', + installedVersion: installedVersion, + updateAvailable: false, + bundleId: bundleId, + ); + } + + bool updateAvailable = false; + try { + updateAvailable = + Version.parse(storeVersion) > Version.parse(installedVersion); + } on FormatException { + // Invalid semver, updateAvailable stays false + } + + return AppUpdateInfoIos( + storeVersion: storeVersion, + installedVersion: installedVersion, + updateAvailable: updateAvailable, + bundleId: bundleId, + ); + } finally { + client.close(); + } +} diff --git a/lib/src/method_channel/in_app_update_flutter_method_channel.dart b/lib/src/method_channel/in_app_update_flutter_method_channel.dart index e150bf4..84a7e71 100644 --- a/lib/src/method_channel/in_app_update_flutter_method_channel.dart +++ b/lib/src/method_channel/in_app_update_flutter_method_channel.dart @@ -1,38 +1,80 @@ +import 'dart:io'; + import 'package:flutter/services.dart'; +import 'package:in_app_update_flutter/src/ios_update_check.dart'; import 'package:in_app_update_flutter/src/models/models.dart'; -import 'package:in_app_update_flutter/src/platform_interface/in_app_update_flutter_platform_interface.dart'; -/// An implementation of [InAppUpdateFlutterPlatform] that uses method channels. -class MethodChannelInAppUpdateFlutter extends InAppUpdateFlutterPlatform { - /// The method channel used to interact with the native platform. +/// Handles platform communication via method channels for Android +/// and delegates to pure Dart for iOS update checks. +class MethodChannelInAppUpdateFlutter { static const MethodChannel _methodChannel = MethodChannel( 'in_app_update_flutter', ); - /// The event channel for receiving install state updates during flexible updates. static const EventChannel _eventChannel = EventChannel( 'in_app_update_flutter/installStateAndroid', ); - @override + Future checkUpdate({String? iosAppStoreRegion}) async { + if (Platform.isIOS) { + final info = + await checkUpdateIosImpl(iosAppStoreRegion: iosAppStoreRegion); + return AppUpdateInfo.fromIos(info); + } else if (Platform.isAndroid) { + final info = await checkUpdateAndroid(); + return AppUpdateInfo.fromAndroid(info); + } + throw UnsupportedError('Unsupported platform'); + } + + Future checkAndUpdate( + {String? iosAppStoreRegion, + String? appStoreId, + AndroidUpdateType androidUpdateType = + AndroidUpdateType.immediate}) async { + final info = await checkUpdate(iosAppStoreRegion: iosAppStoreRegion); + if (info.updateAvailable) { + await startUpdate( + appStoreId: appStoreId, androidUpdateType: androidUpdateType); + } + } + + Future startUpdate( + {String? appStoreId, + AndroidUpdateType androidUpdateType = + AndroidUpdateType.immediate}) async { + if (Platform.isIOS) { + if (appStoreId == null) { + throw ArgumentError('appStoreId is required on iOS'); + } + await showUpdateForIos(appStoreId: appStoreId); + } else if (Platform.isAndroid) { + if (androidUpdateType == AndroidUpdateType.flexible) { + await startFlexibleUpdateAndroid(); + } else { + await startImmediateUpdateAndroid(); + } + } else { + throw UnsupportedError('Unsupported platform'); + } + } + @Deprecated( 'Use showUpdateForIos() on iOS or checkUpdateAndroid() + ' 'startImmediateUpdateAndroid()/startFlexibleUpdateAndroid() on Android', ) - Future showUpdate({required String appStoreId}) async { - await _methodChannel.invokeMethod('showStoreUpdateIos', { - 'appStoreId': appStoreId, - }); - } + Future showUpdate({required String appStoreId}) => + showUpdateForIos(appStoreId: appStoreId); - @override Future showUpdateForIos({required String appStoreId}) async { await _methodChannel.invokeMethod('showStoreUpdateIos', { 'appStoreId': appStoreId, }); } - @override + Future checkUpdateIos({String? iosAppStoreRegion}) => + checkUpdateIosImpl(iosAppStoreRegion: iosAppStoreRegion); + Future checkUpdateAndroid() async { final result = await _methodChannel.invokeMapMethod( 'checkForUpdateAndroid', @@ -40,34 +82,32 @@ class MethodChannelInAppUpdateFlutter extends InAppUpdateFlutterPlatform { return AppUpdateInfoAndroid.fromMap(result!); } - @override Future startImmediateUpdateAndroid({ bool allowAssetPackDeletion = false, - }) async { - final result = await _methodChannel.invokeMethod( - 'startImmediateUpdateAndroid', - {'allowAssetPackDeletion': allowAssetPackDeletion}, - ); - return UpdateResultAndroid.fromValue(result!); - } + }) => + _startUpdateAndroid( + 'startImmediateUpdateAndroid', allowAssetPackDeletion); - @override Future startFlexibleUpdateAndroid({ bool allowAssetPackDeletion = false, - }) async { + }) => + _startUpdateAndroid('startFlexibleUpdateAndroid', allowAssetPackDeletion); + + Future _startUpdateAndroid( + String method, + bool allowAssetPackDeletion, + ) async { final result = await _methodChannel.invokeMethod( - 'startFlexibleUpdateAndroid', + method, {'allowAssetPackDeletion': allowAssetPackDeletion}, ); return UpdateResultAndroid.fromValue(result!); } - @override Future completeUpdateAndroid() async { await _methodChannel.invokeMethod('completeUpdateAndroid'); } - @override Stream get installStateStreamAndroid { return _eventChannel.receiveBroadcastStream().map((event) { return InstallStateAndroid.fromMap( diff --git a/lib/src/models/app_update_info.dart b/lib/src/models/app_update_info.dart new file mode 100644 index 0000000..4d72008 --- /dev/null +++ b/lib/src/models/app_update_info.dart @@ -0,0 +1,63 @@ +import 'package:in_app_update_flutter/src/models/models.dart'; + +/// The detected platform for the current device. +enum AppPlatform { android, ios } + +/// A unified cross-platform result from [checkUpdate]. +/// +/// Contains common update info regardless of platform, plus +/// platform-specific details when available. +class AppUpdateInfo { + /// The detected platform. + final AppPlatform platform; + + /// Whether an update is available. + final bool updateAvailable; + + /// The currently installed version string. + final String? installedVersion; + + /// The latest version available on the store. + final String? storeVersion; + + /// Android-specific update info. `null` on iOS. + final AppUpdateInfoAndroid? androidInfo; + + /// iOS-specific update info. `null` on Android. + final AppUpdateInfoIos? iosInfo; + + const AppUpdateInfo({ + required this.platform, + required this.updateAvailable, + this.installedVersion, + this.storeVersion, + this.androidInfo, + this.iosInfo, + }); + + /// Creates an [AppUpdateInfo] from an Android result. + factory AppUpdateInfo.fromAndroid(AppUpdateInfoAndroid info) { + return AppUpdateInfo( + platform: AppPlatform.android, + updateAvailable: + info.updateAvailability == UpdateAvailabilityAndroid.updateAvailable, + androidInfo: info, + ); + } + + /// Creates an [AppUpdateInfo] from an iOS result. + factory AppUpdateInfo.fromIos(AppUpdateInfoIos info) { + return AppUpdateInfo( + platform: AppPlatform.ios, + updateAvailable: info.updateAvailable, + installedVersion: info.installedVersion, + storeVersion: info.storeVersion, + iosInfo: info, + ); + } + + @override + String toString() => + 'AppUpdateInfo(platform: $platform, updateAvailable: $updateAvailable, ' + 'installed: $installedVersion, store: $storeVersion)'; +} diff --git a/lib/src/models/app_update_info_ios.dart b/lib/src/models/app_update_info_ios.dart new file mode 100644 index 0000000..1021982 --- /dev/null +++ b/lib/src/models/app_update_info_ios.dart @@ -0,0 +1,26 @@ +/// Information about an iOS app update retrieved from the App Store. +class AppUpdateInfoIos { + /// The latest version available on the App Store. + final String storeVersion; + + /// The currently installed version of the app. + final String installedVersion; + + /// Whether an update is available (store version > installed version). + final bool updateAvailable; + + /// The bundle ID used to look up the App Store listing. + final String bundleId; + + const AppUpdateInfoIos({ + required this.storeVersion, + required this.installedVersion, + required this.updateAvailable, + required this.bundleId, + }); + + @override + String toString() => + 'AppUpdateInfoIos(bundleId: $bundleId, installed: $installedVersion, ' + 'store: $storeVersion, updateAvailable: $updateAvailable)'; +} diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart index 47b8d0c..f259cac 100644 --- a/lib/src/models/models.dart +++ b/lib/src/models/models.dart @@ -1,5 +1,8 @@ +export 'app_update_info.dart'; export 'app_update_info_android.dart'; +export 'app_update_info_ios.dart'; export 'install_state_android.dart'; export 'install_status_android.dart'; export 'update_availability_android.dart'; +export 'update_config.dart'; export 'update_result_android.dart'; diff --git a/lib/src/models/update_config.dart b/lib/src/models/update_config.dart new file mode 100644 index 0000000..e4dca31 --- /dev/null +++ b/lib/src/models/update_config.dart @@ -0,0 +1,37 @@ +/// The type of update flow to use on Android. +enum AndroidUpdateType { + /// Full-screen, blocking update the user must accept. + immediate, + + /// Background download while the user continues using the app. + flexible, +} + +/// Configuration for [InAppUpdateFlutter]. +/// +/// Provides default values for all update operations so you don't need to +/// pass the same parameters on every call. +class UpdateConfig { + /// The numeric App Store ID of your iOS app. + /// + /// Required on iOS for presenting the App Store product page via StoreKit. + /// Found in your App Store Connect URL. + final String? appStoreId; + + /// The App Store region to check for iOS updates (e.g., `"us"`, `"gb"`). + /// + /// If `null`, the default region is used. + final String? iosAppStoreRegion; + + /// The default update type on Android. + /// + /// Used by [InAppUpdateFlutter.startUpdate] and [checkAndUpdate]. + /// Defaults to [AndroidUpdateType.immediate]. + final AndroidUpdateType androidUpdateType; + + const UpdateConfig({ + this.appStoreId, + this.iosAppStoreRegion, + this.androidUpdateType = AndroidUpdateType.immediate, + }); +} diff --git a/lib/src/platform_interface/in_app_update_flutter_platform_interface.dart b/lib/src/platform_interface/in_app_update_flutter_platform_interface.dart deleted file mode 100644 index f2613d3..0000000 --- a/lib/src/platform_interface/in_app_update_flutter_platform_interface.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:in_app_update_flutter/src/method_channel/in_app_update_flutter_method_channel.dart'; -import 'package:in_app_update_flutter/src/models/models.dart'; - -/// The platform interface for the `in_app_update_flutter` plugin. -/// -/// This class defines the API that platform-specific implementations -/// must implement. It uses the [PlatformInterface] pattern to ensure -/// safe extension and prevent accidental breaking changes. -abstract class InAppUpdateFlutterPlatform extends PlatformInterface { - /// Constructs an InAppUpdateFlutterPlatform. - InAppUpdateFlutterPlatform() : super(token: _token); - - static final Object _token = Object(); - - static InAppUpdateFlutterPlatform _instance = - MethodChannelInAppUpdateFlutter(); - - /// The default instance of [InAppUpdateFlutterPlatform] to use. - /// - /// Defaults to [MethodChannelInAppUpdateFlutter]. - static InAppUpdateFlutterPlatform get instance => _instance; - - /// Platform-specific implementations should set this with their own - /// platform-specific class that extends [InAppUpdateFlutterPlatform] when - /// they register themselves. - static set instance(InAppUpdateFlutterPlatform instance) { - PlatformInterface.verifyToken(instance, _token); - _instance = instance; - } - - /// Shows the platform-specific in-app update UI. - /// - /// On iOS, this presents the App Store product page using StoreKit. - /// [appStoreId] is the numeric App Store ID of your app. - @Deprecated( - 'Use showUpdateForIos() on iOS or checkUpdateAndroid() + ' - 'startImmediateUpdateAndroid()/startFlexibleUpdateAndroid() on Android', - ) - Future showUpdate({required String appStoreId}) { - throw UnimplementedError('showUpdate() has not been implemented.'); - } - - /// iOS: Shows the App Store product page overlay via StoreKit. - /// - /// [appStoreId] is the numeric App Store ID of your app - /// (found in your App Store Connect URL). - Future showUpdateForIos({required String appStoreId}) { - throw UnimplementedError('showUpdateForIos() has not been implemented.'); - } - - /// Android: Checks whether an in-app update is available via Play Core. - /// - /// Returns an [AppUpdateInfoAndroid] containing update metadata such as - /// availability, version code, priority, staleness, and allowed update types. - Future checkUpdateAndroid() { - throw UnimplementedError('checkUpdateAndroid() has not been implemented.'); - } - - /// Android: Starts the immediate (full-screen, blocking) update flow. - /// - /// If [allowAssetPackDeletion] is `true`, the system may delete asset packs - /// to free up storage for the update. - Future startImmediateUpdateAndroid({ - bool allowAssetPackDeletion = false, - }) { - throw UnimplementedError( - 'startImmediateUpdateAndroid() has not been implemented.', - ); - } - - /// Android: Starts the flexible (background download) update flow. - /// - /// If [allowAssetPackDeletion] is `true`, the system may delete asset packs - /// to free up storage for the update. - /// - /// Listen to [installStateStreamAndroid] for download progress. - /// Call [completeUpdateAndroid] when the download is complete. - Future startFlexibleUpdateAndroid({ - bool allowAssetPackDeletion = false, - }) { - throw UnimplementedError( - 'startFlexibleUpdateAndroid() has not been implemented.', - ); - } - - /// Android: Completes a flexible update by triggering an app restart. - /// - /// Call this after [installStateStreamAndroid] reports - /// [InstallStatusAndroid.downloaded]. - Future completeUpdateAndroid() { - throw UnimplementedError( - 'completeUpdateAndroid() has not been implemented.', - ); - } - - /// Android: A stream of install state changes during a flexible update. - /// - /// Emits [InstallStateAndroid] events with download progress and status. - Stream get installStateStreamAndroid { - throw UnimplementedError( - 'installStateStreamAndroid has not been implemented.', - ); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 920db29..28e7989 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,12 +18,13 @@ environment: dependencies: flutter: sdk: flutter - plugin_platform_interface: ^2.0.2 + package_info_plus: ^10.1.0 + pub_semver: ^2.1.4 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: plugin: diff --git a/test/in_app_update_flutter_method_channel_test.dart b/test/in_app_update_flutter_method_channel_test.dart deleted file mode 100644 index 86c7825..0000000 --- a/test/in_app_update_flutter_method_channel_test.dart +++ /dev/null @@ -1,222 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:in_app_update_flutter/in_app_update_flutter.dart'; -import 'package:in_app_update_flutter/src/method_channel/in_app_update_flutter_method_channel.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const methodChannel = MethodChannel('in_app_update_flutter'); - - late MethodChannelInAppUpdateFlutter plugin; - - setUp(() { - plugin = MethodChannelInAppUpdateFlutter(); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - }); - - group('MethodChannelInAppUpdateFlutter', () { - group('showUpdateForIos', () { - test('calls showStoreUpdateIos method on the channel', () async { - String? invokedMethod; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - invokedMethod = call.method; - return null; - }); - - await plugin.showUpdateForIos(appStoreId: '544007664'); - expect(invokedMethod, 'showStoreUpdateIos'); - }); - - test('passes appStoreId argument to the channel', () async { - Map? invokedArgs; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - invokedArgs = call.arguments as Map; - return null; - }); - - await plugin.showUpdateForIos(appStoreId: '544007664'); - expect(invokedArgs, {'appStoreId': '544007664'}); - }); - - test('propagates platform errors', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - throw PlatformException( - code: 'STORE_ERROR', - message: 'Failed to load product', - ); - }); - - expect( - () => plugin.showUpdateForIos(appStoreId: '544007664'), - throwsA(isA()), - ); - }); - }); - - group('showUpdate (deprecated)', () { - test('calls showStoreUpdateIos method on the channel', () async { - String? invokedMethod; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - invokedMethod = call.method; - return null; - }); - - // ignore: deprecated_member_use_from_same_package - await plugin.showUpdate(appStoreId: '544007664'); - expect(invokedMethod, 'showStoreUpdateIos'); - }); - }); - - group('checkUpdateAndroid', () { - test('calls checkForUpdateAndroid and deserializes response', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - expect(call.method, 'checkForUpdateAndroid'); - return { - 'updateAvailability': 2, - 'availableVersionCode': 42, - 'updatePriority': 3, - 'clientVersionStalenessDays': 7, - 'isImmediateUpdateAllowed': true, - 'isFlexibleUpdateAllowed': true, - 'installStatus': 0, - }; - }); - - final info = await plugin.checkUpdateAndroid(); - expect( - info.updateAvailability, - UpdateAvailabilityAndroid.updateAvailable, - ); - expect(info.availableVersionCode, 42); - expect(info.updatePriority, 3); - expect(info.clientVersionStalenessDays, 7); - expect(info.isImmediateUpdateAllowed, true); - expect(info.isFlexibleUpdateAllowed, true); - expect(info.installStatus, InstallStatusAndroid.unknown); - }); - - test('handles null optional fields', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - return { - 'updateAvailability': 1, - 'availableVersionCode': null, - 'updatePriority': 0, - 'clientVersionStalenessDays': null, - 'isImmediateUpdateAllowed': false, - 'isFlexibleUpdateAllowed': false, - 'installStatus': 0, - }; - }); - - final info = await plugin.checkUpdateAndroid(); - expect( - info.updateAvailability, - UpdateAvailabilityAndroid.updateNotAvailable, - ); - expect(info.availableVersionCode, isNull); - expect(info.clientVersionStalenessDays, isNull); - }); - - test('propagates platform errors', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - throw PlatformException( - code: 'CHECK_UPDATE_FAILED', - message: 'Failed to check for updates', - ); - }); - - expect( - () => plugin.checkUpdateAndroid(), - throwsA(isA()), - ); - }); - }); - - group('startImmediateUpdateAndroid', () { - test('calls startImmediateUpdateAndroid with default args', () async { - Map? invokedArgs; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - expect(call.method, 'startImmediateUpdateAndroid'); - invokedArgs = call.arguments as Map; - return 0; - }); - - final result = await plugin.startImmediateUpdateAndroid(); - expect(result, UpdateResultAndroid.success); - expect(invokedArgs?['allowAssetPackDeletion'], false); - }); - - test('passes allowAssetPackDeletion argument', () async { - Map? invokedArgs; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - invokedArgs = call.arguments as Map; - return 0; - }); - - await plugin.startImmediateUpdateAndroid(allowAssetPackDeletion: true); - expect(invokedArgs?['allowAssetPackDeletion'], true); - }); - - test('returns userCanceled when result is 1', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - return 1; - }); - - final result = await plugin.startImmediateUpdateAndroid(); - expect(result, UpdateResultAndroid.userCanceled); - }); - - test('returns inAppUpdateFailed when result is 2', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - return 2; - }); - - final result = await plugin.startImmediateUpdateAndroid(); - expect(result, UpdateResultAndroid.inAppUpdateFailed); - }); - }); - - group('startFlexibleUpdateAndroid', () { - test('calls startFlexibleUpdateAndroid and returns success', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - expect(call.method, 'startFlexibleUpdateAndroid'); - return 0; - }); - - final result = await plugin.startFlexibleUpdateAndroid(); - expect(result, UpdateResultAndroid.success); - }); - }); - - group('completeUpdateAndroid', () { - test('calls completeUpdateAndroid on the channel', () async { - String? invokedMethod; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (call) async { - invokedMethod = call.method; - return null; - }); - - await plugin.completeUpdateAndroid(); - expect(invokedMethod, 'completeUpdateAndroid'); - }); - }); - }); -} diff --git a/test/in_app_update_flutter_test.dart b/test/in_app_update_flutter_test.dart index 09f61da..3deda70 100644 --- a/test/in_app_update_flutter_test.dart +++ b/test/in_app_update_flutter_test.dart @@ -1,191 +1,221 @@ -import 'package:in_app_update_flutter/in_app_update_flutter.dart'; -import 'package:in_app_update_flutter/src/method_channel/in_app_update_flutter_method_channel.dart'; -import 'package:in_app_update_flutter/src/platform_interface/in_app_update_flutter_platform_interface.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; - -/// A valid mock that extends [InAppUpdateFlutterPlatform], giving it -/// the correct token so it can be set as the platform instance. -class _MockPlatform extends InAppUpdateFlutterPlatform { - String? lastAppStoreId; - - @override - Future showUpdateForIos({required String appStoreId}) async { - lastAppStoreId = appStoreId; - } - - @override - // ignore: deprecated_member_use_from_same_package - Future showUpdate({required String appStoreId}) async { - lastAppStoreId = appStoreId; - } - - @override - Future checkUpdateAndroid() async { - return const AppUpdateInfoAndroid( - updateAvailability: UpdateAvailabilityAndroid.updateAvailable, - availableVersionCode: 42, - updatePriority: 3, - clientVersionStalenessDays: 7, - isImmediateUpdateAllowed: true, - isFlexibleUpdateAllowed: true, - installStatus: InstallStatusAndroid.unknown, - ); - } - - @override - Future startImmediateUpdateAndroid({ - bool allowAssetPackDeletion = false, - }) async { - return UpdateResultAndroid.success; - } - - @override - Future startFlexibleUpdateAndroid({ - bool allowAssetPackDeletion = false, - }) async { - return UpdateResultAndroid.success; - } - - @override - Future completeUpdateAndroid() async {} - - @override - Stream get installStateStreamAndroid => - const Stream.empty(); -} - -/// Minimal subclass that does NOT override the abstract methods, -/// so calls fall through to the base-class throw. -class _UnimplementedPlatform extends InAppUpdateFlutterPlatform {} +import 'package:in_app_update_flutter/in_app_update_flutter.dart'; void main() { - group('InAppUpdateFlutterPlatform', () { - tearDown(() { - // Restore default instance after each test. - InAppUpdateFlutterPlatform.instance = MethodChannelInAppUpdateFlutter(); - }); + TestWidgetsFlutterBinding.ensureInitialized(); - test('default instance is MethodChannelInAppUpdateFlutter', () { - expect( - InAppUpdateFlutterPlatform.instance, - isA(), - ); - }); + const methodChannel = MethodChannel('in_app_update_flutter'); - test('instance can be replaced with a valid mock', () { - final mock = _MockPlatform(); - InAppUpdateFlutterPlatform.instance = mock; - expect(InAppUpdateFlutterPlatform.instance, same(mock)); - }); + late InAppUpdateFlutter plugin; - test('setting instance back to MethodChannel works', () { - InAppUpdateFlutterPlatform.instance = _MockPlatform(); - InAppUpdateFlutterPlatform.instance = MethodChannelInAppUpdateFlutter(); - expect( - InAppUpdateFlutterPlatform.instance, - isA(), - ); - }); + setUp(() { + plugin = InAppUpdateFlutter(); + }); - group('base class throws UnimplementedError', () { - late _UnimplementedPlatform platform; + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + }); - setUp(() { - platform = _UnimplementedPlatform(); + group('InAppUpdateFlutter', () { + group('showUpdateForIos', () { + test('calls showStoreUpdateIos method on the channel', () async { + String? invokedMethod; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + invokedMethod = call.method; + return null; + }); + + await plugin.showUpdateForIos(appStoreId: '544007664'); + expect(invokedMethod, 'showStoreUpdateIos'); }); - test('showUpdate throws UnimplementedError', () { - expect( - // ignore: deprecated_member_use_from_same_package - () => platform.showUpdate(appStoreId: '123'), - throwsA(isA()), - ); - }); + test('passes appStoreId argument to the channel', () async { + Map? invokedArgs; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + invokedArgs = call.arguments as Map; + return null; + }); - test('showUpdateForIos throws UnimplementedError', () { - expect( - () => platform.showUpdateForIos(appStoreId: '123'), - throwsA(isA()), - ); + await plugin.showUpdateForIos(appStoreId: '544007664'); + expect(invokedArgs, {'appStoreId': '544007664'}); }); - test('checkUpdateAndroid throws UnimplementedError', () { + test('propagates platform errors', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + throw PlatformException( + code: 'STORE_ERROR', + message: 'Failed to load product', + ); + }); + expect( - () => platform.checkUpdateAndroid(), - throwsA(isA()), + () => plugin.showUpdateForIos(appStoreId: '544007664'), + throwsA(isA()), ); }); + }); - test('startImmediateUpdateAndroid throws UnimplementedError', () { - expect( - () => platform.startImmediateUpdateAndroid(), - throwsA(isA()), - ); + group('showUpdate (deprecated)', () { + test('calls showStoreUpdateIos method on the channel', () async { + String? invokedMethod; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + invokedMethod = call.method; + return null; + }); + + // ignore: deprecated_member_use_from_same_package + await plugin.showUpdate(appStoreId: '544007664'); + expect(invokedMethod, 'showStoreUpdateIos'); }); + }); - test('startFlexibleUpdateAndroid throws UnimplementedError', () { + group('checkUpdateAndroid', () { + test('calls checkForUpdateAndroid and deserializes response', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + expect(call.method, 'checkForUpdateAndroid'); + return { + 'updateAvailability': 2, + 'availableVersionCode': 42, + 'updatePriority': 3, + 'clientVersionStalenessDays': 7, + 'isImmediateUpdateAllowed': true, + 'isFlexibleUpdateAllowed': true, + 'installStatus': 0, + }; + }); + + final info = await plugin.checkUpdateAndroid(); expect( - () => platform.startFlexibleUpdateAndroid(), - throwsA(isA()), + info.updateAvailability, + UpdateAvailabilityAndroid.updateAvailable, ); + expect(info.availableVersionCode, 42); + expect(info.updatePriority, 3); + expect(info.clientVersionStalenessDays, 7); + expect(info.isImmediateUpdateAllowed, true); + expect(info.isFlexibleUpdateAllowed, true); + expect(info.installStatus, InstallStatusAndroid.unknown); }); - test('completeUpdateAndroid throws UnimplementedError', () { + test('handles null optional fields', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + return { + 'updateAvailability': 1, + 'availableVersionCode': null, + 'updatePriority': 0, + 'clientVersionStalenessDays': null, + 'isImmediateUpdateAllowed': false, + 'isFlexibleUpdateAllowed': false, + 'installStatus': 0, + }; + }); + + final info = await plugin.checkUpdateAndroid(); expect( - () => platform.completeUpdateAndroid(), - throwsA(isA()), + info.updateAvailability, + UpdateAvailabilityAndroid.updateNotAvailable, ); + expect(info.availableVersionCode, isNull); + expect(info.clientVersionStalenessDays, isNull); }); - test('installStateStreamAndroid throws UnimplementedError', () { + test('propagates platform errors', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + throw PlatformException( + code: 'CHECK_UPDATE_FAILED', + message: 'Failed to check for updates', + ); + }); + expect( - () => platform.installStateStreamAndroid, - throwsA(isA()), + () => plugin.checkUpdateAndroid(), + throwsA(isA()), ); }); }); - group('mock platform delegates', () { - test('showUpdateForIos passes appStoreId to mock', () async { - final mock = _MockPlatform(); - InAppUpdateFlutterPlatform.instance = mock; + group('startImmediateUpdateAndroid', () { + test('calls startImmediateUpdateAndroid with default args', () async { + Map? invokedArgs; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + expect(call.method, 'startImmediateUpdateAndroid'); + invokedArgs = call.arguments as Map; + return 0; + }); + + final result = await plugin.startImmediateUpdateAndroid(); + expect(result, UpdateResultAndroid.success); + expect(invokedArgs?['allowAssetPackDeletion'], false); + }); - await InAppUpdateFlutterPlatform.instance - .showUpdateForIos(appStoreId: '544007664'); - expect(mock.lastAppStoreId, '544007664'); + test('passes allowAssetPackDeletion argument', () async { + Map? invokedArgs; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + invokedArgs = call.arguments as Map; + return 0; + }); + + await plugin.startImmediateUpdateAndroid(allowAssetPackDeletion: true); + expect(invokedArgs?['allowAssetPackDeletion'], true); }); - test('checkUpdateAndroid returns expected info', () async { - final mock = _MockPlatform(); - InAppUpdateFlutterPlatform.instance = mock; + test('returns userCanceled when result is 1', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + return 1; + }); - final info = - await InAppUpdateFlutterPlatform.instance.checkUpdateAndroid(); - expect( - info.updateAvailability, - UpdateAvailabilityAndroid.updateAvailable, - ); - expect(info.availableVersionCode, 42); - expect(info.updatePriority, 3); + final result = await plugin.startImmediateUpdateAndroid(); + expect(result, UpdateResultAndroid.userCanceled); }); - test('startImmediateUpdateAndroid returns success', () async { - final mock = _MockPlatform(); - InAppUpdateFlutterPlatform.instance = mock; + test('returns inAppUpdateFailed when result is 2', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + return 2; + }); - final result = await InAppUpdateFlutterPlatform.instance - .startImmediateUpdateAndroid(); - expect(result, UpdateResultAndroid.success); + final result = await plugin.startImmediateUpdateAndroid(); + expect(result, UpdateResultAndroid.inAppUpdateFailed); }); + }); - test('startFlexibleUpdateAndroid returns success', () async { - final mock = _MockPlatform(); - InAppUpdateFlutterPlatform.instance = mock; + group('startFlexibleUpdateAndroid', () { + test('calls startFlexibleUpdateAndroid and returns success', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + expect(call.method, 'startFlexibleUpdateAndroid'); + return 0; + }); - final result = await InAppUpdateFlutterPlatform.instance - .startFlexibleUpdateAndroid(); + final result = await plugin.startFlexibleUpdateAndroid(); expect(result, UpdateResultAndroid.success); }); }); + + group('completeUpdateAndroid', () { + test('calls completeUpdateAndroid on the channel', () async { + String? invokedMethod; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (call) async { + invokedMethod = call.method; + return null; + }); + + await plugin.completeUpdateAndroid(); + expect(invokedMethod, 'completeUpdateAndroid'); + }); + }); }); }