Skip to content

[image_picker] Android: getPathFromUri silently returns a truncated file when a cloud-backed provider's stream ends early #193077

Description

@tim-bitflinger

Steps to reproduce

  1. On Android, pick an image from Google Photos (or any cloud-backed provider) whose bytes are not fully present on the device — a photo that is in the cloud and not locally cached. ImagePicker().pickImage(source: ImageSource.gallery), no maxWidth/maxHeight/imageQuality, so the plugin does not re-encode.
  2. Read the result: final bytes = await picked.readAsBytes();
  3. Compare bytes.length against the original file's size.

Reproduced repeatedly with the same photo. Saving that same photo to the device first (Downloads) and picking it from there returns the whole file every time, so the trigger is the provider, not the file.

Expected results

Either the whole file, or a failed pick. If the plugin cannot copy an item in full it should report the existing missing_valid_image_uri error rather than hand back a path to a partial file.

Actual results

XFile points at a silently truncated copy. No exception, no error code, nothing in the logs.

A 3.77 MB JPEG came back as 3,201,028 bytes — 15% short. That number is exactly 781 complete 4 KiB reads plus a final 2,052-byte read, after which read() returned -1:

781 * 4096 + 2052 = 3,201,028

The truncated copy has no FFD9 end-of-image marker. A strict decoder confirms it:

OSError: image file is truncated (5 bytes not processed)

Decoded leniently, the last 448 of 3,024 rows are missing.

Root cause

FileUtils.copy treats read() == -1 as end-of-file, and nothing compares the result against the size the provider advertises:

https://github.com/flutter/packages/blob/main/packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java

private static void copy(InputStream in, OutputStream out) throws IOException {
  final byte[] buffer = new byte[4 * 1024];
  int bytesRead;
  while ((bytesRead = in.read(buffer)) != -1) {
    out.write(buffer, 0, bytesRead);
  }
  out.flush();
}

For a local file -1 does mean end-of-file. For a cloud-backed provider the stream is network-fed, and a stalled or dropped fetch ends it early without throwing IOException — so getPathFromUri returns a path to a short file and every caller downstream believes it is whole.

This is the silent sibling of #191988 (a null stream from the same class of provider, fixed by the null guard now on main). The comment added there already anticipates the scenario — "for example after the provider crashed, or for a cloud-only photo it could not fetch" — but only for the case where the provider serves nothing at all. When it serves some bytes, the copy still reports success.

Why this is worse than an ordinary read error

A truncated JPEG or PNG keeps a perfectly valid header. Anything that measures an image by parsing its container — including dart:ui's own ImageDescriptor.encoded — reports the correct full dimensions, so the file passes validation, gets uploaded, stored, cached, whatever the app does with it. The failure only surfaces later at decode time, from the engine, as:

Exception: Could not decompress image.

(SkCodec::getPixels on incomplete input, surfaced by image_decoder_impeller.cc.) That message is indistinguishable from a genuinely corrupt or unsupported file, so an app cannot tell the user the one thing that would actually help them — that the photo needs to finish downloading first. We only found it by pulling the stored bytes back out of S3 and checking for the EOI marker.

Suggested fix

Query OpenableColumns.SIZE before the copy and compare it to the bytes written. When the provider declares a size and the copy falls short, delete the partial temp file and return null from getPathFromUri, which routes onto the existing missing_valid_image_uri path:

private static long copy(InputStream in, OutputStream out) throws IOException {
  final byte[] buffer = new byte[4 * 1024];
  int bytesRead;
  long total = 0;
  while ((bytesRead = in.read(buffer)) != -1) {
    out.write(buffer, 0, bytesRead);
    total += bytesRead;
  }
  out.flush();
  return total;
}

and at the call site, compare against the declared size when there is one. OpenableColumns.SIZE is documented as optional and some providers return null or 0 for it, so a missing size should keep today's behaviour rather than start failing picks — the check only fires when the provider told us a number and we did not reach it.

An alternative worth considering is surfacing a distinct error code for this case rather than reusing missing_valid_image_uri, since "the photo is not on the device yet" is recoverable by the user in a way that an unreadable URI is not.

Code sample

Code sample
final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
if (picked == null) return;

final bytes = await picked.readAsBytes();
debugPrint('read ${bytes.length} bytes from ${picked.name}');

// A truncated JPEG still reports its true, full dimensions here.
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final descriptor = await ui.ImageDescriptor.encoded(buffer);
debugPrint('header says ${descriptor.width}x${descriptor.height}');

// ...and only fails here, with "Could not decompress image."
final codec = await descriptor.instantiateCodec();
await codec.getNextFrame();

Logs

Logs
read 3201028 bytes from 20200808_150808.jpg
header says 4032x3024

Exception: Could not decompress image.

Nothing is logged by the plugin itself — that is the substance of the report. The copy completes normally and getPathFromUri returns a path.

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.44.4, on macOS 26.5.2 25F84 darwin-arm64, locale en-US)
[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 26.2)
[✓] Chrome - develop for the web
[✓] Connected device (6 available)
[✓] Network resources

• No issues found!

image_picker: 1.2.3, image_picker_android: 0.8.13+23. Not environment-specific — the copy loop is identical on main.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions