[ios_platform_images] Convert to Pigeon (#4945)

Replaces the manual platform method code with Pigeon.

Also adds some additional Dart unit tests. Since the needs are minimal, this uses a manual fake rather than introducing Mockito.

Fixes https://github.com/flutter/flutter/issues/117911
diff --git a/packages/ios_platform_images/CHANGELOG.md b/packages/ios_platform_images/CHANGELOG.md
index 69781d6..0025950 100644
--- a/packages/ios_platform_images/CHANGELOG.md
+++ b/packages/ios_platform_images/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.2.2+3
+
+* Converts platform communication to Pigeon.
+
 ## 0.2.2+2
 
 * Adds pub topics to package metadata.
diff --git a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj
index acea2f5..ba03518 100644
--- a/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/packages/ios_platform_images/example/ios/Runner.xcodeproj/project.pbxproj
@@ -222,7 +222,7 @@
 		97C146E61CF9000F007C117D /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastUpgradeCheck = 1300;
+				LastUpgradeCheck = 1430;
 				ORGANIZATIONNAME = "The Flutter Authors";
 				TargetAttributes = {
 					97C146ED1CF9000F007C117D = {
diff --git a/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index 7ae2cb4..51e700a 100644
--- a/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/packages/ios_platform_images/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "1300"
+   LastUpgradeVersion = "1430"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"
diff --git a/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.h b/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.h
index f3c8efe..b5dcc7b 100644
--- a/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.h
+++ b/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.h
@@ -4,7 +4,9 @@
 
 #import <Flutter/Flutter.h>
 
+#import "messages.g.h"
+
 /// A plugin for Flutter that allows Flutter to load images in a platform
 /// specific way on iOS.
-@interface IosPlatformImagesPlugin : NSObject <FlutterPlugin>
+@interface IosPlatformImagesPlugin : NSObject <FlutterPlugin, FPIPlatformImagesApi>
 @end
diff --git a/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.m b/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.m
index 5f7debc..b41ff99 100644
--- a/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.m
+++ b/packages/ios_platform_images/ios/Classes/IosPlatformImagesPlugin.m
@@ -14,35 +14,27 @@
 @implementation IosPlatformImagesPlugin
 
 + (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar {
-  FlutterMethodChannel *channel =
-      [FlutterMethodChannel methodChannelWithName:@"plugins.flutter.io/ios_platform_images"
-                                  binaryMessenger:[registrar messenger]];
+  FPIPlatformImagesApiSetup(registrar.messenger, [[IosPlatformImagesPlugin alloc] init]);
+}
 
-  [channel setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) {
-    if ([@"loadImage" isEqualToString:call.method]) {
-      NSString *name = call.arguments;
-      UIImage *image = [UIImage imageNamed:name];
-      NSData *data = UIImagePNGRepresentation(image);
-      if (data) {
-        result(@{
-          @"scale" : @(image.scale),
-          @"data" : [FlutterStandardTypedData typedDataWithBytes:data],
-        });
-      } else {
-        result(nil);
-      }
-      return;
-    } else if ([@"resolveURL" isEqualToString:call.method]) {
-      NSArray *args = call.arguments;
-      NSString *name = args[0];
-      NSString *extension = (args[1] == (id)NSNull.null) ? nil : args[1];
+- (nullable FPIPlatformImageData *)
+    loadImageWithName:(nonnull NSString *)name
+                error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error {
+  UIImage *image = [UIImage imageNamed:name];
+  NSData *data = UIImagePNGRepresentation(image);
+  if (!data) {
+    return nil;
+  }
+  return [FPIPlatformImageData makeWithData:[FlutterStandardTypedData typedDataWithBytes:data]
+                                      scale:@(image.scale)];
+}
 
-      NSURL *url = [[NSBundle mainBundle] URLForResource:name withExtension:extension];
-      result(url.absoluteString);
-      return;
-    }
-    result(FlutterMethodNotImplemented);
-  }];
+- (nullable NSString *)resolveURLForResource:(nonnull NSString *)name
+                               withExtension:(nullable NSString *)extension
+                                       error:(FlutterError *_Nullable __autoreleasing *_Nonnull)
+                                                 error {
+  NSURL *url = [[NSBundle mainBundle] URLForResource:name withExtension:extension];
+  return url.absoluteString;
 }
 
 @end
diff --git a/packages/ios_platform_images/ios/Classes/messages.g.h b/packages/ios_platform_images/ios/Classes/messages.g.h
new file mode 100644
index 0000000..f4235cf
--- /dev/null
+++ b/packages/ios_platform_images/ios/Classes/messages.g.h
@@ -0,0 +1,47 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+// Autogenerated from Pigeon (v11.0.1), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+#import <Foundation/Foundation.h>
+
+@protocol FlutterBinaryMessenger;
+@protocol FlutterMessageCodec;
+@class FlutterError;
+@class FlutterStandardTypedData;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class FPIPlatformImageData;
+
+/// A serialization of a platform image's data.
+@interface FPIPlatformImageData : NSObject
+/// `init` unavailable to enforce nonnull fields, see the `make` class method.
+- (instancetype)init NS_UNAVAILABLE;
++ (instancetype)makeWithData:(FlutterStandardTypedData *)data scale:(NSNumber *)scale;
+/// The image data.
+@property(nonatomic, strong) FlutterStandardTypedData *data;
+/// The image's scale factor.
+@property(nonatomic, strong) NSNumber *scale;
+@end
+
+/// The codec used by FPIPlatformImagesApi.
+NSObject<FlutterMessageCodec> *FPIPlatformImagesApiGetCodec(void);
+
+@protocol FPIPlatformImagesApi
+/// Returns the URL for the given resource, or null if no such resource is
+/// found.
+- (nullable NSString *)resolveURLForResource:(NSString *)resourceName
+                               withExtension:(nullable NSString *)extension
+                                       error:(FlutterError *_Nullable *_Nonnull)error;
+/// Returns the data for the image resource with the given name, or null if
+/// no such resource is found.
+- (nullable FPIPlatformImageData *)loadImageWithName:(NSString *)name
+                                               error:(FlutterError *_Nullable *_Nonnull)error;
+@end
+
+extern void FPIPlatformImagesApiSetup(id<FlutterBinaryMessenger> binaryMessenger,
+                                      NSObject<FPIPlatformImagesApi> *_Nullable api);
+
+NS_ASSUME_NONNULL_END
diff --git a/packages/ios_platform_images/ios/Classes/messages.g.m b/packages/ios_platform_images/ios/Classes/messages.g.m
new file mode 100644
index 0000000..b96e65e
--- /dev/null
+++ b/packages/ios_platform_images/ios/Classes/messages.g.m
@@ -0,0 +1,163 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+// Autogenerated from Pigeon (v11.0.1), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+
+#import "messages.g.h"
+
+#if TARGET_OS_OSX
+#import <FlutterMacOS/FlutterMacOS.h>
+#else
+#import <Flutter/Flutter.h>
+#endif
+
+#if !__has_feature(objc_arc)
+#error File requires ARC to be enabled.
+#endif
+
+static NSArray *wrapResult(id result, FlutterError *error) {
+  if (error) {
+    return @[
+      error.code ?: [NSNull null], error.message ?: [NSNull null], error.details ?: [NSNull null]
+    ];
+  }
+  return @[ result ?: [NSNull null] ];
+}
+static id GetNullableObjectAtIndex(NSArray *array, NSInteger key) {
+  id result = array[key];
+  return (result == [NSNull null]) ? nil : result;
+}
+
+@interface FPIPlatformImageData ()
++ (FPIPlatformImageData *)fromList:(NSArray *)list;
++ (nullable FPIPlatformImageData *)nullableFromList:(NSArray *)list;
+- (NSArray *)toList;
+@end
+
+@implementation FPIPlatformImageData
++ (instancetype)makeWithData:(FlutterStandardTypedData *)data scale:(NSNumber *)scale {
+  FPIPlatformImageData *pigeonResult = [[FPIPlatformImageData alloc] init];
+  pigeonResult.data = data;
+  pigeonResult.scale = scale;
+  return pigeonResult;
+}
++ (FPIPlatformImageData *)fromList:(NSArray *)list {
+  FPIPlatformImageData *pigeonResult = [[FPIPlatformImageData alloc] init];
+  pigeonResult.data = GetNullableObjectAtIndex(list, 0);
+  NSAssert(pigeonResult.data != nil, @"");
+  pigeonResult.scale = GetNullableObjectAtIndex(list, 1);
+  NSAssert(pigeonResult.scale != nil, @"");
+  return pigeonResult;
+}
++ (nullable FPIPlatformImageData *)nullableFromList:(NSArray *)list {
+  return (list) ? [FPIPlatformImageData fromList:list] : nil;
+}
+- (NSArray *)toList {
+  return @[
+    (self.data ?: [NSNull null]),
+    (self.scale ?: [NSNull null]),
+  ];
+}
+@end
+
+@interface FPIPlatformImagesApiCodecReader : FlutterStandardReader
+@end
+@implementation FPIPlatformImagesApiCodecReader
+- (nullable id)readValueOfType:(UInt8)type {
+  switch (type) {
+    case 128:
+      return [FPIPlatformImageData fromList:[self readValue]];
+    default:
+      return [super readValueOfType:type];
+  }
+}
+@end
+
+@interface FPIPlatformImagesApiCodecWriter : FlutterStandardWriter
+@end
+@implementation FPIPlatformImagesApiCodecWriter
+- (void)writeValue:(id)value {
+  if ([value isKindOfClass:[FPIPlatformImageData class]]) {
+    [self writeByte:128];
+    [self writeValue:[value toList]];
+  } else {
+    [super writeValue:value];
+  }
+}
+@end
+
+@interface FPIPlatformImagesApiCodecReaderWriter : FlutterStandardReaderWriter
+@end
+@implementation FPIPlatformImagesApiCodecReaderWriter
+- (FlutterStandardWriter *)writerWithData:(NSMutableData *)data {
+  return [[FPIPlatformImagesApiCodecWriter alloc] initWithData:data];
+}
+- (FlutterStandardReader *)readerWithData:(NSData *)data {
+  return [[FPIPlatformImagesApiCodecReader alloc] initWithData:data];
+}
+@end
+
+NSObject<FlutterMessageCodec> *FPIPlatformImagesApiGetCodec(void) {
+  static FlutterStandardMessageCodec *sSharedObject = nil;
+  static dispatch_once_t sPred = 0;
+  dispatch_once(&sPred, ^{
+    FPIPlatformImagesApiCodecReaderWriter *readerWriter =
+        [[FPIPlatformImagesApiCodecReaderWriter alloc] init];
+    sSharedObject = [FlutterStandardMessageCodec codecWithReaderWriter:readerWriter];
+  });
+  return sSharedObject;
+}
+
+void FPIPlatformImagesApiSetup(id<FlutterBinaryMessenger> binaryMessenger,
+                               NSObject<FPIPlatformImagesApi> *api) {
+  /// Returns the URL for the given resource, or null if no such resource is
+  /// found.
+  {
+    FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc]
+           initWithName:@"dev.flutter.pigeon.ios_platform_images.PlatformImagesApi.resolveUrl"
+        binaryMessenger:binaryMessenger
+                  codec:FPIPlatformImagesApiGetCodec()];
+    if (api) {
+      NSCAssert([api respondsToSelector:@selector(resolveURLForResource:withExtension:error:)],
+                @"FPIPlatformImagesApi api (%@) doesn't respond to "
+                @"@selector(resolveURLForResource:withExtension:error:)",
+                api);
+      [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) {
+        NSArray *args = message;
+        NSString *arg_resourceName = GetNullableObjectAtIndex(args, 0);
+        NSString *arg_extension = GetNullableObjectAtIndex(args, 1);
+        FlutterError *error;
+        NSString *output = [api resolveURLForResource:arg_resourceName
+                                        withExtension:arg_extension
+                                                error:&error];
+        callback(wrapResult(output, error));
+      }];
+    } else {
+      [channel setMessageHandler:nil];
+    }
+  }
+  /// Returns the data for the image resource with the given name, or null if
+  /// no such resource is found.
+  {
+    FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc]
+           initWithName:@"dev.flutter.pigeon.ios_platform_images.PlatformImagesApi.loadImage"
+        binaryMessenger:binaryMessenger
+                  codec:FPIPlatformImagesApiGetCodec()];
+    if (api) {
+      NSCAssert(
+          [api respondsToSelector:@selector(loadImageWithName:error:)],
+          @"FPIPlatformImagesApi api (%@) doesn't respond to @selector(loadImageWithName:error:)",
+          api);
+      [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) {
+        NSArray *args = message;
+        NSString *arg_name = GetNullableObjectAtIndex(args, 0);
+        FlutterError *error;
+        FPIPlatformImageData *output = [api loadImageWithName:arg_name error:&error];
+        callback(wrapResult(output, error));
+      }];
+    } else {
+      [channel setMessageHandler:nil];
+    }
+  }
+}
diff --git a/packages/ios_platform_images/lib/ios_platform_images.dart b/packages/ios_platform_images/lib/ios_platform_images.dart
index b372d36..22e0b84 100644
--- a/packages/ios_platform_images/lib/ios_platform_images.dart
+++ b/packages/ios_platform_images/lib/ios_platform_images.dart
@@ -6,10 +6,17 @@
 import 'dart:ui' as ui;
 
 import 'package:flutter/foundation.dart'
-    show SynchronousFuture, describeIdentity, immutable, objectRuntimeType;
+    show
+        SynchronousFuture,
+        describeIdentity,
+        immutable,
+        objectRuntimeType,
+        visibleForTesting;
 import 'package:flutter/rendering.dart';
 import 'package:flutter/services.dart';
 
+import 'src/messages.g.dart';
+
 class _FutureImageStreamCompleter extends ImageStreamCompleter {
   _FutureImageStreamCompleter({
     required Future<ui.Codec> codec,
@@ -101,14 +108,22 @@
       '(${describeIdentity(_futureBytes)}, scale: $_futureScale)';
 }
 
+PlatformImagesApi _hostApi = PlatformImagesApi();
+
+/// Sets the [PlatformImagesApi] instance used to implement the static methods
+/// of [IosPlatformImages].
+///
+/// This exists only for unit tests.
+@visibleForTesting
+void setPlatformImageHostApi(PlatformImagesApi api) {
+  _hostApi = api;
+}
+
 // ignore: avoid_classes_with_only_static_members
 /// Class to help loading of iOS platform images into Flutter.
 ///
 /// For example, loading an image that is in `Assets.xcassts`.
 class IosPlatformImages {
-  static const MethodChannel _channel =
-      MethodChannel('plugins.flutter.io/ios_platform_images');
-
   /// Loads an image from asset catalogs.  The equivalent would be:
   /// `[UIImage imageNamed:name]`.
   ///
@@ -116,12 +131,11 @@
   ///
   /// See [https://developer.apple.com/documentation/uikit/uiimage/1624146-imagenamed?language=objc]
   static ImageProvider load(String name) {
-    final Future<Map<String, dynamic>?> loadInfo =
-        _channel.invokeMapMethod<String, dynamic>('loadImage', name);
+    final Future<PlatformImageData?> imageData = _hostApi.loadImage(name);
     final Completer<Uint8List> bytesCompleter = Completer<Uint8List>();
     final Completer<double> scaleCompleter = Completer<double>();
-    loadInfo.then((Map<String, dynamic>? map) {
-      if (map == null) {
+    imageData.then((PlatformImageData? image) {
+      if (image == null) {
         scaleCompleter.completeError(
           Exception("Image couldn't be found: $name"),
         );
@@ -130,8 +144,8 @@
         );
         return;
       }
-      scaleCompleter.complete(map['scale']! as double);
-      bytesCompleter.complete(map['data']! as Uint8List);
+      scaleCompleter.complete(image.scale);
+      bytesCompleter.complete(image.data);
     });
     return _FutureMemoryImage(bytesCompleter.future, scaleCompleter.future);
   }
@@ -143,7 +157,6 @@
   ///
   /// See [https://developer.apple.com/documentation/foundation/nsbundle/1411540-urlforresource?language=objc]
   static Future<String?> resolveURL(String name, {String? extension}) {
-    return _channel
-        .invokeMethod<String>('resolveURL', <Object?>[name, extension]);
+    return _hostApi.resolveUrl(name, extension);
   }
 }
diff --git a/packages/ios_platform_images/lib/src/messages.g.dart b/packages/ios_platform_images/lib/src/messages.g.dart
new file mode 100644
index 0000000..a3a5ab4
--- /dev/null
+++ b/packages/ios_platform_images/lib/src/messages.g.dart
@@ -0,0 +1,126 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+// Autogenerated from Pigeon (v11.0.1), do not edit directly.
+// See also: https://pub.dev/packages/pigeon
+// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import
+
+import 'dart:async';
+import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
+
+import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
+import 'package:flutter/services.dart';
+
+/// A serialization of a platform image's data.
+class PlatformImageData {
+  PlatformImageData({
+    required this.data,
+    required this.scale,
+  });
+
+  /// The image data.
+  Uint8List data;
+
+  /// The image's scale factor.
+  double scale;
+
+  Object encode() {
+    return <Object?>[
+      data,
+      scale,
+    ];
+  }
+
+  static PlatformImageData decode(Object result) {
+    result as List<Object?>;
+    return PlatformImageData(
+      data: result[0]! as Uint8List,
+      scale: result[1]! as double,
+    );
+  }
+}
+
+class _PlatformImagesApiCodec extends StandardMessageCodec {
+  const _PlatformImagesApiCodec();
+  @override
+  void writeValue(WriteBuffer buffer, Object? value) {
+    if (value is PlatformImageData) {
+      buffer.putUint8(128);
+      writeValue(buffer, value.encode());
+    } else {
+      super.writeValue(buffer, value);
+    }
+  }
+
+  @override
+  Object? readValueOfType(int type, ReadBuffer buffer) {
+    switch (type) {
+      case 128:
+        return PlatformImageData.decode(readValue(buffer)!);
+      default:
+        return super.readValueOfType(type, buffer);
+    }
+  }
+}
+
+class PlatformImagesApi {
+  /// Constructor for [PlatformImagesApi].  The [binaryMessenger] named argument is
+  /// available for dependency injection.  If it is left null, the default
+  /// BinaryMessenger will be used which routes to the host platform.
+  PlatformImagesApi({BinaryMessenger? binaryMessenger})
+      : _binaryMessenger = binaryMessenger;
+  final BinaryMessenger? _binaryMessenger;
+
+  static const MessageCodec<Object?> codec = _PlatformImagesApiCodec();
+
+  /// Returns the URL for the given resource, or null if no such resource is
+  /// found.
+  Future<String?> resolveUrl(
+      String arg_resourceName, String? arg_extension) async {
+    final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+        'dev.flutter.pigeon.ios_platform_images.PlatformImagesApi.resolveUrl',
+        codec,
+        binaryMessenger: _binaryMessenger);
+    final List<Object?>? replyList = await channel
+        .send(<Object?>[arg_resourceName, arg_extension]) as List<Object?>?;
+    if (replyList == null) {
+      throw PlatformException(
+        code: 'channel-error',
+        message: 'Unable to establish connection on channel.',
+      );
+    } else if (replyList.length > 1) {
+      throw PlatformException(
+        code: replyList[0]! as String,
+        message: replyList[1] as String?,
+        details: replyList[2],
+      );
+    } else {
+      return (replyList[0] as String?);
+    }
+  }
+
+  /// Returns the data for the image resource with the given name, or null if
+  /// no such resource is found.
+  Future<PlatformImageData?> loadImage(String arg_name) async {
+    final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
+        'dev.flutter.pigeon.ios_platform_images.PlatformImagesApi.loadImage',
+        codec,
+        binaryMessenger: _binaryMessenger);
+    final List<Object?>? replyList =
+        await channel.send(<Object?>[arg_name]) as List<Object?>?;
+    if (replyList == null) {
+      throw PlatformException(
+        code: 'channel-error',
+        message: 'Unable to establish connection on channel.',
+      );
+    } else if (replyList.length > 1) {
+      throw PlatformException(
+        code: replyList[0]! as String,
+        message: replyList[1] as String?,
+        details: replyList[2],
+      );
+    } else {
+      return (replyList[0] as PlatformImageData?);
+    }
+  }
+}
diff --git a/packages/ios_platform_images/pigeons/copyright.txt b/packages/ios_platform_images/pigeons/copyright.txt
new file mode 100644
index 0000000..1236b63
--- /dev/null
+++ b/packages/ios_platform_images/pigeons/copyright.txt
@@ -0,0 +1,3 @@
+Copyright 2013 The Flutter Authors. All rights reserved.
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
diff --git a/packages/ios_platform_images/pigeons/messages.dart b/packages/ios_platform_images/pigeons/messages.dart
new file mode 100644
index 0000000..d0a8d34
--- /dev/null
+++ b/packages/ios_platform_images/pigeons/messages.dart
@@ -0,0 +1,37 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:pigeon/pigeon.dart';
+
+@ConfigurePigeon(PigeonOptions(
+  dartOut: 'lib/src/messages.g.dart',
+  objcOptions: ObjcOptions(prefix: 'FPI'),
+  objcHeaderOut: 'ios/Classes/messages.g.h',
+  objcSourceOut: 'ios/Classes/messages.g.m',
+  copyrightHeader: 'pigeons/copyright.txt',
+))
+
+/// A serialization of a platform image's data.
+class PlatformImageData {
+  PlatformImageData(this.data, this.scale);
+
+  /// The image data.
+  final Uint8List data;
+
+  /// The image's scale factor.
+  final double scale;
+}
+
+@HostApi()
+abstract class PlatformImagesApi {
+  /// Returns the URL for the given resource, or null if no such resource is
+  /// found.
+  @ObjCSelector('resolveURLForResource:withExtension:')
+  String? resolveUrl(String resourceName, String? extension);
+
+  /// Returns the data for the image resource with the given name, or null if
+  /// no such resource is found.
+  @ObjCSelector('loadImageWithName:')
+  PlatformImageData? loadImage(String name);
+}
diff --git a/packages/ios_platform_images/pubspec.yaml b/packages/ios_platform_images/pubspec.yaml
index 7a14d4a..c4a0eee 100644
--- a/packages/ios_platform_images/pubspec.yaml
+++ b/packages/ios_platform_images/pubspec.yaml
@@ -2,7 +2,7 @@
 description: A plugin to share images between Flutter and iOS in add-to-app setups.
 repository: https://github.com/flutter/packages/tree/main/packages/ios_platform_images
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+ios_platform_images%22
-version: 0.2.2+2
+version: 0.2.2+3
 
 environment:
   sdk: ">=2.19.0 <4.0.0"
@@ -21,6 +21,7 @@
 dev_dependencies:
   flutter_test:
     sdk: flutter
+  pigeon: ^11.0.0
 
 topics:
   - image
diff --git a/packages/ios_platform_images/test/ios_platform_images_test.dart b/packages/ios_platform_images/test/ios_platform_images_test.dart
index f42b786..04605c1 100644
--- a/packages/ios_platform_images/test/ios_platform_images_test.dart
+++ b/packages/ios_platform_images/test/ios_platform_images_test.dart
@@ -5,34 +5,63 @@
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:ios_platform_images/ios_platform_images.dart';
+import 'package:ios_platform_images/src/messages.g.dart';
 
 void main() {
-  const MethodChannel channel =
-      MethodChannel('plugins.flutter.io/ios_platform_images');
-
-  TestWidgetsFlutterBinding.ensureInitialized();
+  late FakePlatformImagesApi fakeApi;
 
   setUp(() {
-    _ambiguate(TestDefaultBinaryMessengerBinding.instance)!
-        .defaultBinaryMessenger
-        .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
-      return '42';
-    });
+    fakeApi = FakePlatformImagesApi();
+    setPlatformImageHostApi(fakeApi);
   });
 
-  tearDown(() {
-    _ambiguate(TestDefaultBinaryMessengerBinding.instance)!
-        .defaultBinaryMessenger
-        .setMockMethodCallHandler(channel, null);
+  test('resolveURL passes arguments', () async {
+    const String name = 'a name';
+    const String extension = '.extension';
+
+    await IosPlatformImages.resolveURL(name, extension: extension);
+
+    expect(fakeApi.passedName, name);
+    expect(fakeApi.passedExtension, extension);
   });
 
-  test('resolveURL', () async {
-    expect(await IosPlatformImages.resolveURL('foobar'), '42');
+  test('resolveURL returns null', () async {
+    expect(await IosPlatformImages.resolveURL('foobar'), null);
+  });
+
+  test('resolveURL returns result', () async {
+    const String result = 'a result';
+    fakeApi.resolutionResult = result;
+
+    expect(await IosPlatformImages.resolveURL('foobar'), result);
+  });
+
+  test('loadImage passes argument', () async {
+    fakeApi.loadResult = PlatformImageData(data: Uint8List(1), scale: 1.0);
+    const String name = 'a name';
+
+    IosPlatformImages.load(name);
+
+    expect(fakeApi.passedName, name);
   });
 }
 
-/// This allows a value of type T or T? to be treated as a value of type T?.
-///
-/// We use this so that APIs that have become non-nullable can still be used
-/// with `!` and `?` on the stable branch.
-T? _ambiguate<T>(T? value) => value;
+class FakePlatformImagesApi implements PlatformImagesApi {
+  String? passedName;
+  String? passedExtension;
+  String? resolutionResult;
+  PlatformImageData? loadResult;
+
+  @override
+  Future<PlatformImageData?> loadImage(String name) async {
+    passedName = name;
+    return loadResult;
+  }
+
+  @override
+  Future<String?> resolveUrl(String name, String? extension) async {
+    passedName = name;
+    passedExtension = extension;
+    return resolutionResult;
+  }
+}