From 79e3d6e6de9b497c47b37ac28e9ace350b6a7d7c Mon Sep 17 00:00:00 2001 From: Alex Li Date: Thu, 16 Jul 2026 16:19:26 +0800 Subject: [PATCH 1/3] feat(platform_interface): add optional code field to CompressError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an optional `code` field on `CompressError` so callers can branch on failure kind without string-matching the message. Stable values emitted by the native side: `unsupported_format`, `decode_failed`, `encode_failed`, `io_failed`, `unknown`. Unknown codes pass through verbatim for forward-compat. Additive change — `code` is nullable and the existing single-arg constructor is unchanged. Refs #252 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/src/errors.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/flutter_image_compress_platform_interface/lib/src/errors.dart b/packages/flutter_image_compress_platform_interface/lib/src/errors.dart index 84fd9356..271a1f82 100644 --- a/packages/flutter_image_compress_platform_interface/lib/src/errors.dart +++ b/packages/flutter_image_compress_platform_interface/lib/src/errors.dart @@ -1,8 +1,16 @@ class CompressError extends Error { - CompressError(this.message); + CompressError(this.message, {this.code}); final String message; + /// Optional machine-readable failure code emitted by the native side. + /// + /// Stable values: `unsupported_format`, `decode_failed`, `encode_failed`, + /// `io_failed`, `unknown`. Unknown codes are passed through verbatim so + /// forward-compat callers can still branch on them without a plugin bump. + final String? code; + @override - String toString() => 'CompressError: $message'; + String toString() => + code == null ? 'CompressError: $message' : 'CompressError($code): $message'; } From 20a2d14d77d2e2dd6fe7b559453057b0c54b69b3 Mon Sep 17 00:00:00 2001 From: Alex Li Date: Thu, 16 Jul 2026 16:20:02 +0800 Subject: [PATCH 2/3] fix(*): translate native compress failures to CompressError instead of null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native compress failures on Android/iOS/macOS previously reported back to Dart as `reply(null)` / `result(nil)`, forcing callers to do a bare null-check with no diagnostic — and creating a latent `TypeError` on `compressWithList` (common), whose signature is non-nullable but which happily returned the native null unchanged. Native side (Android Kotlin / iOS Objective-C / macOS Swift): - `reply(null)` / `result(nil)` on the failure path becomes `replyError(code, msg)` / `result([FlutterError ...])` / `FlutterError(code:..., message:...)` with a stable code: `unsupported_format`, `decode_failed`, `encode_failed`, `io_failed`, `unknown`. - iOS `handleCompressFileToFile:` also translates `writeToURL:` failure from a silent `result(nil)` to `io_failed` with the target path in the message. Dart side (common / macOS / ohos): - Introduce a private `_invoke` helper on each platform impl that catches `PlatformException` and rethrows `CompressError(msg, code: ...)`. Every compress-related `_channel.invokeMethod` call now goes through it. - Delete three unreachable `if (!support) return null;` branches in common — `FlutterImageCompressValidator.checkSupportPlatform` already throws `UnsupportedError` when a format is not supported; it never actually returns `false`. - Add a defensive `if (result == null) throw CompressError(...)` in `compressWithList` (common), aligning with the guard macOS and ohos already had. This closes the latent `TypeError` — the native side should no longer emit a `success(null)` on the failure path, but the guard keeps the non-nullable return type honest. - Replace `throw Exception('Compress failed')` in macOS and ohos `compressWithList` with `throw CompressError('Compress failed')` for consistency. Existing null-checking callers still compile — public signatures unchanged. Callers who already catch `CompressError` now get a real error kind + message instead of an opaque null return. Refs #252, refs #396 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/CompressFileHandler.kt | 8 ++-- .../core/CompressListHandler.kt | 6 +-- .../CompressFileHandler.m | 32 ++++++++++--- .../CompressListHandler.m | 4 +- .../lib/flutter_image_compress_common.dart | 45 +++++++++++-------- .../lib/flutter_image_compress_macos.dart | 18 ++++++-- .../FlutterImageCompressMacosPlugin.swift | 8 +++- .../lib/flutter_image_compress_ohos.dart | 18 ++++++-- 8 files changed, 95 insertions(+), 44 deletions(-) diff --git a/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressFileHandler.kt b/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressFileHandler.kt index 751aa99f..e2d7068a 100644 --- a/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressFileHandler.kt +++ b/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressFileHandler.kt @@ -29,7 +29,7 @@ class CompressFileHandler(private val call: MethodCall, result: MethodChannel.Re val formatHandler = FormatRegister.findFormat(format) if (formatHandler == null) { log("No support format.") - reply(null) + replyError("unsupported_format", "No handler for format=$format") return@execute } val outputStream = ByteArrayOutputStream() @@ -61,7 +61,7 @@ class CompressFileHandler(private val call: MethodCall, result: MethodChannel.Re reply(outputStream.toByteArray()) } catch (e: Exception) { if (ImageCompressPlugin.showLog) e.printStackTrace() - reply(null) + replyError("unknown", e.message ?: "compress failed") } finally { outputStream.close() } @@ -86,7 +86,7 @@ class CompressFileHandler(private val call: MethodCall, result: MethodChannel.Re val formatHandler = FormatRegister.findFormat(format) if (formatHandler == null) { log("No support format.") - reply(null) + replyError("unsupported_format", "No handler for format=$format") return@execute } var outputStream: OutputStream? = null @@ -119,7 +119,7 @@ class CompressFileHandler(private val call: MethodCall, result: MethodChannel.Re reply(targetPath) } catch (e: Exception) { if (ImageCompressPlugin.showLog) e.printStackTrace() - reply(null) + replyError("unknown", e.message ?: "compress failed") } finally { outputStream?.close() } diff --git a/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressListHandler.kt b/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressListHandler.kt index 547f53e7..00a6ccda 100644 --- a/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressListHandler.kt +++ b/packages/flutter_image_compress_common/android/src/main/kotlin/com/fluttercandies/flutter_image_compress/core/CompressListHandler.kt @@ -32,7 +32,7 @@ class CompressListHandler(private val call: MethodCall, result: MethodChannel.Re val formatHandler = FormatRegister.findFormat(format) if (formatHandler == null) { log("No support format.") - reply(null) + replyError("unsupported_format", "No handler for format=$format") return@execute } val targetRotate = rotate + exifRotate @@ -53,10 +53,10 @@ class CompressListHandler(private val call: MethodCall, result: MethodChannel.Re } catch (e: CompressError) { log(e.message) if (ImageCompressPlugin.showLog) e.printStackTrace() - reply(null) + replyError("encode_failed", e.message ?: "compress failed") } catch (e: Exception) { if (ImageCompressPlugin.showLog) e.printStackTrace() - reply(null) + replyError("unknown", e.message ?: "compress failed") } finally { outputStream.close() } diff --git a/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressFileHandler.m b/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressFileHandler.m index e5e2133d..327127d2 100644 --- a/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressFileHandler.m +++ b/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressFileHandler.m @@ -33,7 +33,9 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result if ([ImageCompressPlugin showLog]) { NSLog(@"Input file could not be read (path=%@)", path); } - result(nil); + result([FlutterError errorWithCode:@"io_failed" + message:[NSString stringWithFormat:@"Input file could not be read (path=%@)", path] + details:nil]); return; } @@ -54,7 +56,9 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result if ([ImageCompressPlugin showLog]) { NSLog(@"Input file is not a decodable image (mime=%@, path=%@)", imageType, path); } - result(nil); + result([FlutterError errorWithCode:@"decode_failed" + message:[NSString stringWithFormat:@"Input file is not a decodable image (mime=%@, path=%@)", imageType, path] + details:nil]); return; } @@ -75,7 +79,9 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result } if (data == nil) { - result(nil); + result([FlutterError errorWithCode:@"encode_failed" + message:@"Encoder returned no data" + details:nil]); return; } result([FlutterStandardTypedData typedDataWithBytes:data]); @@ -103,7 +109,9 @@ - (void)handleCompressFileToFile:(FlutterMethodCall *)call result:(FlutterResult if ([ImageCompressPlugin showLog]) { NSLog(@"Input file could not be read (path=%@)", path); } - result(nil); + result([FlutterError errorWithCode:@"io_failed" + message:[NSString stringWithFormat:@"Input file could not be read (path=%@)", path] + details:nil]); return; } @@ -124,7 +132,9 @@ - (void)handleCompressFileToFile:(FlutterMethodCall *)call result:(FlutterResult if ([ImageCompressPlugin showLog]) { NSLog(@"Input file is not a decodable image (mime=%@, path=%@)", imageType, path); } - result(nil); + result([FlutterError errorWithCode:@"decode_failed" + message:[NSString stringWithFormat:@"Input file is not a decodable image (mime=%@, path=%@)", imageType, path] + details:nil]); return; } @@ -141,7 +151,9 @@ - (void)handleCompressFileToFile:(FlutterMethodCall *)call result:(FlutterResult } if (data == nil) { - result(nil); + result([FlutterError errorWithCode:@"encode_failed" + message:@"Encoder returned no data" + details:nil]); return; } NSURL *targetURL = [[NSURL alloc] initFileURLWithPath:targetPath]; @@ -161,7 +173,13 @@ - (void)handleCompressFileToFile:(FlutterMethodCall *)call result:(FlutterResult } } BOOL success = [data writeToURL:targetURL atomically:YES]; - result(success ? targetPath : nil); + if (success) { + result(targetPath); + } else { + result([FlutterError errorWithCode:@"io_failed" + message:[NSString stringWithFormat:@"Failed to write compressed data to %@", targetPath] + details:nil]); + } } diff --git a/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressListHandler.m b/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressListHandler.m index 4ab28b26..093f4f6d 100644 --- a/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressListHandler.m +++ b/packages/flutter_image_compress_common/ios/flutter_image_compress_common/Sources/flutter_image_compress_common/CompressListHandler.m @@ -26,7 +26,9 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result NSData *compressedData = [CompressHandler compressWithData:data minWidth:minWidth minHeight:minHeight quality:quality rotate:rotate format:formatType]; if (compressedData == nil) { - result(nil); + result([FlutterError errorWithCode:@"encode_failed" + message:@"Encoder returned no data" + details:nil]); return; } diff --git a/packages/flutter_image_compress_common/lib/flutter_image_compress_common.dart b/packages/flutter_image_compress_common/lib/flutter_image_compress_common.dart index 3085f62a..c95178ef 100644 --- a/packages/flutter_image_compress_common/lib/flutter_image_compress_common.dart +++ b/packages/flutter_image_compress_common/lib/flutter_image_compress_common.dart @@ -42,6 +42,19 @@ class FlutterImageCompressCommon extends FlutterImageCompressPlatform { FlutterImageCompressPlatform.instance = FlutterImageCompressCommon(); } + /// Wrap [MethodChannel.invokeMethod] so native `result.error(...)` / + /// `replyError(...)` calls surface as [CompressError] on the Dart side + /// instead of leaking as [PlatformException] or (worse) a silent null + /// return type-cast failure. The native code carries a stable failure + /// code (see [CompressError.code]) and a human message. + Future _invoke(String method, [dynamic args]) async { + try { + return await _channel.invokeMethod(method, args); + } on PlatformException catch (e) { + throw CompressError(e.message ?? e.code, code: e.code); + } + } + @override Future compressAssetImage(String assetName, {int minWidth = 1920, @@ -51,10 +64,7 @@ class FlutterImageCompressCommon extends FlutterImageCompressPlatform { bool autoCorrectionAngle = true, CompressFormat format = CompressFormat.jpeg, bool keepExif = false}) async { - final support = await _validator.checkSupportPlatform(format); - if (!support) { - return null; - } + await _validator.checkSupportPlatform(format); final img = AssetImage(assetName); const config = ImageConfiguration(); final AssetBundleImageKey key = await img.obtainKey(config); @@ -94,11 +104,8 @@ class FlutterImageCompressCommon extends FlutterImageCompressPlatform { if (!File(path).existsSync()) { throw CompressError('Image file does not exist in $path.'); } - final support = await _validator.checkSupportPlatform(format); - if (!support) { - return null; - } - final result = await _channel.invokeMethod('compressWithFile', [ + await _validator.checkSupportPlatform(format); + final result = await _invoke('compressWithFile', [ path, minWidth, minHeight, @@ -126,11 +133,8 @@ class FlutterImageCompressCommon extends FlutterImageCompressPlatform { if (image.isEmpty) { throw CompressError('The image is empty.'); } - final support = await _validator.checkSupportPlatform(format); - if (!support) { - throw UnsupportedError('The image type $format is not supported.'); - } - final result = await _channel.invokeMethod('compressWithList', [ + await _validator.checkSupportPlatform(format); + final result = await _invoke('compressWithList', [ image, minWidth, minHeight, @@ -141,6 +145,12 @@ class FlutterImageCompressCommon extends FlutterImageCompressPlatform { keepExif, inSampleSize, ]); + // Native failures now surface via `_invoke` as `CompressError`, so a null + // result here means the native side responded with `success(null)` — which + // shouldn't happen after Step 1. Guard defensively to avoid a TypeError. + if (result == null) { + throw CompressError('Compress returned no data'); + } return result; } @@ -173,11 +183,8 @@ class FlutterImageCompressCommon extends FlutterImageCompressPlatform { throw CompressError('Target path and source path cannot be the same.'); } _validator.checkFileNameAndFormat(targetPath, format); - final support = await _validator.checkSupportPlatform(format); - if (!support) { - return null; - } - final String? result = await _channel.invokeMethod( + await _validator.checkSupportPlatform(format); + final String? result = await _invoke( 'compressWithFileAndGetFile', [ path, diff --git a/packages/flutter_image_compress_macos/lib/flutter_image_compress_macos.dart b/packages/flutter_image_compress_macos/lib/flutter_image_compress_macos.dart index ffb0a731..883e650f 100644 --- a/packages/flutter_image_compress_macos/lib/flutter_image_compress_macos.dart +++ b/packages/flutter_image_compress_macos/lib/flutter_image_compress_macos.dart @@ -18,6 +18,16 @@ class FlutterImageCompressMacos extends FlutterImageCompressPlatform { } } + /// See [FlutterImageCompressCommon._invoke] for rationale — native + /// `FlutterError` results become [CompressError] on the Dart side. + Future _invoke(String method, [dynamic args]) async { + try { + return await _channel.invokeMethod(method, args); + } on PlatformException catch (e) { + throw CompressError(e.message ?? e.code, code: e.code); + } + } + @override Future compressAndGetFile( String path, @@ -34,7 +44,7 @@ class FlutterImageCompressMacos extends FlutterImageCompressPlatform { }) async { await checkSupport(format); - final dstPath = await _channel.invokeMethod('compressAndGetFile', { + final dstPath = await _invoke('compressAndGetFile', { 'path': path, 'targetPath': targetPath, 'minWidth': minWidth, @@ -99,7 +109,7 @@ class FlutterImageCompressMacos extends FlutterImageCompressPlatform { }) async { await checkSupport(format); - final result = await _channel.invokeMethod('compressWithFile', { + final result = await _invoke('compressWithFile', { 'path': path, 'minWidth': minWidth, 'minHeight': minHeight, @@ -133,7 +143,7 @@ class FlutterImageCompressMacos extends FlutterImageCompressPlatform { }) async { await checkSupport(format); - final result = await _channel.invokeMethod('compressWithList', { + final result = await _invoke('compressWithList', { 'list': image, 'minWidth': minWidth, 'minHeight': minHeight, @@ -146,7 +156,7 @@ class FlutterImageCompressMacos extends FlutterImageCompressPlatform { }); if (result == null) { - throw Exception('Compress failed'); + throw CompressError('Compress failed'); } return result; diff --git a/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift b/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift index a1773dd1..b964d7a1 100644 --- a/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift +++ b/packages/flutter_image_compress_macos/macos/flutter_image_compress_macos/Sources/flutter_image_compress_macos/FlutterImageCompressMacosPlugin.swift @@ -399,7 +399,9 @@ class Compressor { } if (!ok) { - result(nil) + result(FlutterError(code: "encode_failed", + message: "Failed to encode compressed image to \(path)", + details: nil)) return } @@ -418,7 +420,9 @@ class Compressor { } if (!ok) { - result(nil) + result(FlutterError(code: "encode_failed", + message: "Failed to encode compressed image", + details: nil)) return } diff --git a/packages/flutter_image_compress_ohos/lib/flutter_image_compress_ohos.dart b/packages/flutter_image_compress_ohos/lib/flutter_image_compress_ohos.dart index 0e8d99a8..b07a5dc9 100644 --- a/packages/flutter_image_compress_ohos/lib/flutter_image_compress_ohos.dart +++ b/packages/flutter_image_compress_ohos/lib/flutter_image_compress_ohos.dart @@ -18,6 +18,16 @@ class FlutterImageCompressOhos extends FlutterImageCompressPlatform { } } + /// See [FlutterImageCompressCommon._invoke] for rationale — native + /// `result.error(...)` becomes [CompressError] on the Dart side. + Future _invoke(String method, [dynamic args]) async { + try { + return await _channel.invokeMethod(method, args); + } on PlatformException catch (e) { + throw CompressError(e.message ?? e.code, code: e.code); + } + } + @override Future compressAndGetFile( String path, @@ -34,7 +44,7 @@ class FlutterImageCompressOhos extends FlutterImageCompressPlatform { }) async { await checkSupport(format); - final dstPath = await _channel.invokeMethod('compressAndGetFile', { + final dstPath = await _invoke('compressAndGetFile', { 'path': path, 'targetPath': targetPath, 'minWidth': minWidth, @@ -101,7 +111,7 @@ class FlutterImageCompressOhos extends FlutterImageCompressPlatform { }) async { await checkSupport(format); - final result = await _channel.invokeMethod('compressWithFile', { + final result = await _invoke('compressWithFile', { 'path': path, 'minWidth': minWidth, 'minHeight': minHeight, @@ -136,7 +146,7 @@ class FlutterImageCompressOhos extends FlutterImageCompressPlatform { }) async { await checkSupport(format); - final result = await _channel.invokeMethod('compressWithList', { + final result = await _invoke('compressWithList', { 'list': image, 'minWidth': minWidth, 'minHeight': minHeight, @@ -150,7 +160,7 @@ class FlutterImageCompressOhos extends FlutterImageCompressPlatform { }); if (result == null) { - throw Exception('Compress failed'); + throw CompressError('Compress failed'); } return result; From 130343051bd8acbbb14c9071f8a602a5627d406b Mon Sep 17 00:00:00 2001 From: Alex Li Date: Thu, 16 Jul 2026 16:25:53 +0800 Subject: [PATCH 3/3] style(platform_interface): dart format on errors.dart Re-wrap CompressError.toString ternary to match `dart format` output. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/src/errors.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/flutter_image_compress_platform_interface/lib/src/errors.dart b/packages/flutter_image_compress_platform_interface/lib/src/errors.dart index 271a1f82..bc7637e0 100644 --- a/packages/flutter_image_compress_platform_interface/lib/src/errors.dart +++ b/packages/flutter_image_compress_platform_interface/lib/src/errors.dart @@ -11,6 +11,7 @@ class CompressError extends Error { final String? code; @override - String toString() => - code == null ? 'CompressError: $message' : 'CompressError($code): $message'; + String toString() => code == null + ? 'CompressError: $message' + : 'CompressError($code): $message'; }