Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 0.2.0 (unreleased)

### Changed
- **Breaking:** `ScanFromPhotosAsync` is now iOS-only (`[SupportedOSPlatform("ios")]`) and throws `NotSupportedException` on Android — ML Kit cannot start in the gallery, so the Android flow just opened the camera and confused users; Android users reach the gallery through the import button inside `ScanAsync`'s scanner, now always enabled

### Added
- `CancellationToken` parameter on `ScanAsync` and `ScanFromPhotosAsync`; cancelling dismisses the native scanner UI and throws `OperationCanceledException`
- Source Link and symbol package (`.snupkg`) so consumers can step into the library
Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,32 +49,34 @@ Inject `IDocumentScanner` (or use `DocumentScanner.Default` without DI):
// Camera scan — returns file paths of cropped pages, empty list if the user cancels
IReadOnlyList<string> pages = await scanner.ScanAsync();

// Crop already-taken photos from the photo library
IReadOnlyList<string> pages = await scanner.ScanFromPhotosAsync();
// iOS only: pick already-taken photos, then adjust each crop in the corner editor.
// On Android this throws NotSupportedException, so guard it.
if (OperatingSystem.IsIOS())
pages = await scanner.ScanFromPhotosAsync();

// With options
var pages = await scanner.ScanAsync(new DocumentScanOptions
pages = await scanner.ScanAsync(new DocumentScanOptions
{
PageLimit = 3,
Mode = DocumentScannerMode.Base, // Android only: Full, BaseWithFilter, or Base
});

// With cancellation — dismisses the native UI and throws OperationCanceledException
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var pages = await scanner.ScanAsync(cancellationToken: cts.Token);
pages = await scanner.ScanAsync(cancellationToken: cts.Token);
```

Check `scanner.IsSupported` first; `ScanAsync` throws `NotSupportedException` on devices without a scanner implementation.
Check `scanner.IsSupported` first. On Android `ScanAsync` throws `NotSupportedException` when ML Kit reports the device is unsupported (under ~1.7 GB RAM).

Returned files are JPEGs written to the app's cache directory — move or copy them if you need them to persist.

### Platform notes

| | Android | iOS |
|---|---|---|
| `ScanAsync` | ML Kit scanner UI | VisionKit document camera |
| `ScanFromPhotosAsync` | ML Kit scanner with gallery import | Photo picker + auto-detected corners + manual corner editor |
| `PageLimit` | Applies to both methods | Photo import only (VisionKit has no limit) |
| `ScanAsync` | ML Kit scanner UI, with an import-from-gallery button | VisionKit document camera |
| `ScanFromPhotosAsync` | Not supported — ML Kit cannot start in the gallery, so the API throws `NotSupportedException` | Photo picker + auto-detected corners + manual corner editor |
| `PageLimit` | Applies to the scanner | `ScanFromPhotosAsync` only (VisionKit has no limit) |
| `Mode` | Full / BaseWithFilter / Base | Ignored |

## Sample
Expand Down
3 changes: 2 additions & 1 deletion samples/ScanTest/MainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
<Button Text="Scan"
Command="{Binding ScanCommand}" />
<Button Grid.Column="1"
Text="Import Photo"
Text="Import Photos"
IsVisible="{Binding CanImportPhotos}"
Command="{Binding ImportCommand}" />
</Grid>

Expand Down
9 changes: 8 additions & 1 deletion samples/ScanTest/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ public MainViewModel(IDocumentScanner scanner)
{
this.scanner = scanner;
ScanCommand = new Command(async () => await RunAsync(() => scanner.ScanAsync()), () => !busy);
ImportCommand = new Command(async () => await RunAsync(() => scanner.ScanFromPhotosAsync()), () => !busy);
ImportCommand = new Command(async () => await RunAsync(() =>
{
// OperatingSystem.IsIOS() is the platform guard the analyzer recognises
if (!OperatingSystem.IsIOS())
return Task.FromResult<IReadOnlyList<string>>([]);
return scanner.ScanFromPhotosAsync();
}), () => !busy);
}

public ObservableCollection<ScannedPage> Pages { get; } = [];
public Command ScanCommand { get; }
public Command ImportCommand { get; }
public bool CanImportPhotos => OperatingSystem.IsIOS();

public string Status
{
Expand Down
25 changes: 23 additions & 2 deletions src/Plugin.Maui.DocumentScanner/IDocumentScanner.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Runtime.Versioning;

namespace Plugin.Maui.DocumentScanner;

/// <summary>Scans documents with the platform's native scanner UI.</summary>
Expand All @@ -6,11 +8,30 @@ public interface IDocumentScanner
/// <summary>Whether the native scanner is available on this device.</summary>
bool IsSupported { get; }

/// <summary>Opens the camera scanner. Returns file paths of cropped pages; empty on user cancel.</summary>
/// <summary>
/// Opens the native camera scanner and returns file paths of the cropped pages;
/// an empty list if the user cancels. Supported on Android and iOS.
/// </summary>
/// <remarks>
/// Android: the ML Kit scanner. It always starts on the camera and offers an
/// import-from-gallery button inside that UI.
/// iOS: the VisionKit document camera.
/// </remarks>
/// <exception cref="OperationCanceledException">The token was cancelled; the scanner UI is dismissed.</exception>
Task<IReadOnlyList<string>> ScanAsync(DocumentScanOptions? options = null, CancellationToken cancellationToken = default);

/// <summary>Crops already-taken photos into document pages. Returns file paths; empty on user cancel.</summary>
/// <summary>
/// iOS only. Opens the system photo picker for photos that were already taken, then a corner
/// editor to adjust the crop of each one. Returns file paths of the cropped pages;
/// an empty list if the user cancels.
/// </summary>
/// <remarks>
/// There is no Android counterpart: ML Kit's scanner cannot start in the gallery, so calling
/// this on Android always throws. Guard calls with <c>OperatingSystem.IsIOS()</c>. Android
/// users reach the gallery through the import button inside <see cref="ScanAsync"/>'s scanner.
/// </remarks>
/// <exception cref="NotSupportedException">Called on Android.</exception>
/// <exception cref="OperationCanceledException">The token was cancelled; any open UI is dismissed.</exception>
[SupportedOSPlatform("ios")]
Task<IReadOnlyList<string>> ScanFromPhotosAsync(DocumentScanOptions? options = null, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,23 @@ sealed class DocumentScannerImplementation : IDocumentScanner
&& GoogleApiAvailability.Instance.IsGooglePlayServicesAvailable(Platform.AppContext) == ConnectionResult.Success;

public Task<IReadOnlyList<string>> ScanAsync(DocumentScanOptions? options = null, CancellationToken cancellationToken = default) =>
LaunchAsync(galleryImport: false, options ?? DocumentScanOptions.Default, cancellationToken);
LaunchAsync(options ?? DocumentScanOptions.Default, cancellationToken);

// Same scanner UI plus an import-from-gallery button
// ML Kit's scanner always starts on the camera, so there is no gallery-first flow on Android
public Task<IReadOnlyList<string>> ScanFromPhotosAsync(DocumentScanOptions? options = null, CancellationToken cancellationToken = default) =>
LaunchAsync(galleryImport: true, options ?? DocumentScanOptions.Default, cancellationToken);
throw new NotSupportedException(
"ScanFromPhotosAsync is not available on Android: the ML Kit scanner cannot start in the gallery. "
+ "Call ScanAsync instead — its scanner has an import-from-gallery button.");

static async Task<IReadOnlyList<string>> LaunchAsync(bool galleryImport, DocumentScanOptions options, CancellationToken cancellationToken)
static async Task<IReadOnlyList<string>> LaunchAsync(DocumentScanOptions options, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var activity = Platform.CurrentActivity
?? throw new InvalidOperationException("No current activity.");

var scannerOptions = new GmsDocumentScannerOptions.Builder()
.SetPageLimit(options.PageLimit)
.SetGalleryImportAllowed(galleryImport)
.SetGalleryImportAllowed(true)
.SetScannerMode(ToScannerMode(options.Mode))
.SetResultFormats(GmsDocumentScannerOptions.ResultFormatJpeg, [])
.Build();
Expand Down
Loading