From ca01c94f81c47fe35696f7a29f55fe1a8a64689e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 23 Jul 2026 09:40:07 +0900 Subject: [PATCH 1/3] feat: add file printing support --- Package.swift | 2 +- README.md | 58 ++++++- android/build.gradle | 2 + .../plugin/printer/PrinterPlugin.java | 157 +++++++++++++++++- ios/Sources/PrinterPlugin/PrinterPlugin.swift | 58 +++++-- src/definitions.ts | 35 +++- src/web.ts | 12 +- 7 files changed, 305 insertions(+), 19 deletions(-) diff --git a/Package.swift b/Package.swift index 8c003da..66aed80 100644 --- a/Package.swift +++ b/Package.swift @@ -25,4 +25,4 @@ let package = Package( dependencies: ["PrinterPlugin"], path: "ios/Tests/PrinterPluginTests") ] -) \ No newline at end of file +) diff --git a/README.md b/README.md index 4bcddc4..83e01c9 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,71 @@ npx cap sync -* [`printWebView()`](#printwebview) +* [`printFile(...)`](#printfile) +* [`printWebView(...)`](#printwebview) +* [Interfaces](#interfaces) +* [Type Aliases](#type-aliases) -### printWebView() +### printFile(...) ```typescript -printWebView() => Promise +printFile(options: PrintFileOptions) => Promise ``` +Present the printing user interface to print a file. + +Only available on Android and iOS. + +| Param | Type | +| ------------- | ------------------------------------------------------------- | +| **`options`** | PrintFileOptions | + -------------------- + +### printWebView(...) + +```typescript +printWebView(options?: PrintOptions | undefined) => Promise +``` + +Present the printing user interface to print the web view content. + +| Param | Type | +| ------------- | ----------------------------------------------------- | +| **`options`** | PrintOptions | + +-------------------- + + +### Interfaces + + +#### PrintFileOptions + +| Prop | Type | Description | +| -------------- | ------------------- | -------------------------------------------------------------------------- | +| **`path`** | string | The path to the file. Both file paths and file/content URLs are supported. | +| **`mimeType`** | string | The MIME type of the file. Only used on Android. | + + +#### PrintOptions + +| Prop | Type | Description | Default | +| ---------- | ------------------- | -------------------------- | ----------------------- | +| **`name`** | string | The name of the print job. | 'Document' | + + +### Type Aliases + + +#### PrintWebViewOptions + +PrintOptions + diff --git a/android/build.gradle b/android/build.gradle index 69f29b8..6ce7d29 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -3,6 +3,7 @@ ext { androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1' androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0' androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0' + androidxPrintVersion = project.hasProperty('androidxPrintVersion') ? rootProject.ext.androidxPrintVersion : '1.1.0' } buildscript { @@ -52,6 +53,7 @@ dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation project(':capacitor-android') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.print:print:$androidxPrintVersion" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java index 5af31a2..7254479 100644 --- a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java +++ b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java @@ -1,18 +1,60 @@ package jp.rdlabo.capacitor.plugin.printer; import android.content.Context; +import android.net.Uri; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; import android.print.PrintAttributes; import android.print.PrintDocumentAdapter; +import android.print.PrintDocumentInfo; import android.print.PrintManager; import android.webkit.WebView; +import androidx.print.PrintHelper; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Locale; @CapacitorPlugin(name = "Printer") public class PrinterPlugin extends Plugin { + private static final String DEFAULT_JOB_NAME = "Document"; + + @PluginMethod + public void printFile(PluginCall call) { + String path = call.getString("path"); + String mimeType = call.getString("mimeType"); + if (path == null || path.trim().isEmpty()) { + call.reject("path must be provided"); + return; + } + if (mimeType == null || mimeType.trim().isEmpty()) { + call.reject("mimeType must be provided"); + return; + } + + Uri uri = toUri(path); + String jobName = getFileName(uri); + String normalizedMimeType = mimeType.toLowerCase(Locale.ROOT); + getActivity().runOnUiThread(() -> { + if (isSupportedImageMimeType(normalizedMimeType)) { + printImage(call, uri, jobName); + } else if ("application/pdf".equals(normalizedMimeType)) { + printPdf(call, uri, jobName); + } else { + call.reject("Unsupported MIME type: " + mimeType); + } + }); + } + @PluginMethod public void printWebView(PluginCall call) { getActivity().runOnUiThread(() -> { @@ -28,11 +70,124 @@ public void printWebView(PluginCall call) { return; } - String jobName = "WebView Print"; + String jobName = call.getString("name", DEFAULT_JOB_NAME); PrintDocumentAdapter printAdapter = webView.createPrintDocumentAdapter(jobName); printManager.print(jobName, printAdapter, new PrintAttributes.Builder().build()); call.resolve(); }); } + + private void printImage(PluginCall call, Uri uri, String jobName) { + try { + PrintHelper printHelper = new PrintHelper(getContext()); + printHelper.setScaleMode(PrintHelper.SCALE_MODE_FIT); + printHelper.printBitmap(jobName, uri); + call.resolve(); + } catch (FileNotFoundException | SecurityException exception) { + call.reject("Unable to read file: " + exception.getLocalizedMessage(), exception); + } + } + + private void printPdf(PluginCall call, Uri uri, String jobName) { + PrintManager printManager = (PrintManager) getContext().getSystemService(Context.PRINT_SERVICE); + if (printManager == null) { + call.reject("Print service not available"); + return; + } + + try (InputStream ignored = openInputStream(uri)) { + // Validate access before opening the asynchronous print job. + } catch (IOException | SecurityException exception) { + call.reject("Unable to read file: " + exception.getLocalizedMessage(), exception); + return; + } + + printManager.print(jobName, new PdfPrintDocumentAdapter(uri, jobName), new PrintAttributes.Builder().build()); + call.resolve(); + } + + private InputStream openInputStream(Uri uri) throws FileNotFoundException { + if ("file".equalsIgnoreCase(uri.getScheme())) { + return new FileInputStream(new File(uri.getPath())); + } + InputStream input = getContext().getContentResolver().openInputStream(uri); + if (input == null) { + throw new FileNotFoundException("Unable to open " + uri); + } + return input; + } + + private static Uri toUri(String path) { + Uri uri = Uri.parse(path); + return uri.getScheme() == null ? Uri.fromFile(new File(path)) : uri; + } + + private static String getFileName(Uri uri) { + String fileName = uri.getLastPathSegment(); + return fileName == null || fileName.trim().isEmpty() ? DEFAULT_JOB_NAME : fileName; + } + + private static boolean isSupportedImageMimeType(String mimeType) { + return switch (mimeType) { + case "image/gif", "image/heic", "image/heif", "image/jpeg", "image/png" -> true; + default -> false; + }; + } + + private final class PdfPrintDocumentAdapter extends PrintDocumentAdapter { + + private final Uri uri; + private final String name; + + private PdfPrintDocumentAdapter(Uri uri, String name) { + this.uri = uri; + this.name = name; + } + + @Override + public void onLayout( + PrintAttributes oldAttributes, + PrintAttributes newAttributes, + CancellationSignal cancellationSignal, + LayoutResultCallback callback, + android.os.Bundle extras + ) { + if (cancellationSignal.isCanceled()) { + callback.onLayoutCancelled(); + return; + } + PrintDocumentInfo info = new PrintDocumentInfo.Builder(name).setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT).build(); + callback.onLayoutFinished(info, !newAttributes.equals(oldAttributes)); + } + + @Override + public void onWrite( + android.print.PageRange[] pages, + ParcelFileDescriptor destination, + CancellationSignal cancellationSignal, + WriteResultCallback callback + ) { + new Thread(() -> { + try ( + InputStream input = openInputStream(uri); + OutputStream output = new FileOutputStream(destination.getFileDescriptor()) + ) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) != -1) { + if (cancellationSignal.isCanceled()) { + callback.onWriteCancelled(); + return; + } + output.write(buffer, 0, length); + } + callback.onWriteFinished(new android.print.PageRange[] { android.print.PageRange.ALL_PAGES }); + } catch (IOException | SecurityException exception) { + callback.onWriteFailed(exception.getLocalizedMessage()); + } + }) + .start(); + } + } } diff --git a/ios/Sources/PrinterPlugin/PrinterPlugin.swift b/ios/Sources/PrinterPlugin/PrinterPlugin.swift index cda1aef..9f061d7 100644 --- a/ios/Sources/PrinterPlugin/PrinterPlugin.swift +++ b/ios/Sources/PrinterPlugin/PrinterPlugin.swift @@ -11,30 +11,68 @@ public class PrinterPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "PrinterPlugin" public let jsName = "Printer" public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "printFile", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "printWebView", returnType: CAPPluginReturnPromise) ] + @objc func printFile(_ call: CAPPluginCall) { + guard let path = call.getString("path"), !path.isEmpty else { + call.reject("path must be provided") + return + } + + let fileURL: URL + if let url = URL(string: path), url.isFileURL { + fileURL = url + } else { + fileURL = URL(fileURLWithPath: path) + } + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + call.reject("File not found") + return + } + guard UIPrintInteractionController.canPrint(fileURL) else { + call.reject("File type is not printable") + return + } + + DispatchQueue.main.async { + let printController = UIPrintInteractionController.shared + let printInfo = UIPrintInfo(dictionary: nil) + printInfo.outputType = .general + printInfo.jobName = fileURL.lastPathComponent.isEmpty ? "Document" : fileURL.lastPathComponent + printController.printInfo = printInfo + printController.printingItem = fileURL + self.present(printController, call: call) + } + } + @objc func printWebView(_ call: CAPPluginCall) { DispatchQueue.main.async { guard let webView = self.webView else { call.reject("WebView not available") return } - + let printController = UIPrintInteractionController.shared let printInfo = UIPrintInfo(dictionary: nil) printInfo.outputType = .general + printInfo.jobName = call.getString("name") ?? "Document" printController.printInfo = printInfo printController.printFormatter = webView.viewPrintFormatter() - - printController.present(animated: true) { _, completed, error in - if let error = error { - call.reject("Print failed: \(error.localizedDescription)") - } else if completed { - call.resolve() - } else { - call.reject("Print cancelled") - } + self.present(printController, call: call) + } + } + + private func present(_ printController: UIPrintInteractionController, call: CAPPluginCall) { + printController.present(animated: true) { _, completed, error in + if let error = error { + call.reject("Print failed: \(error.localizedDescription)") + } else if completed { + call.resolve() + } else { + call.reject("Print cancelled") } } } diff --git a/src/definitions.ts b/src/definitions.ts index 2d054f3..deeecb5 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -1,3 +1,36 @@ export interface PrinterPlugin { - printWebView(): Promise; + /** + * Present the printing user interface to print a file. + * + * Only available on Android and iOS. + */ + printFile(options: PrintFileOptions): Promise; + + /** + * Present the printing user interface to print the web view content. + */ + printWebView(options?: PrintWebViewOptions): Promise; } + +export interface PrintFileOptions { + /** + * The path to the file. Both file paths and file/content URLs are supported. + */ + path: string; + + /** + * The MIME type of the file. Only used on Android. + */ + mimeType: string; +} + +export interface PrintOptions { + /** + * The name of the print job. + * + * @default 'Document' + */ + name?: string; +} + +export type PrintWebViewOptions = PrintOptions; diff --git a/src/web.ts b/src/web.ts index e80ec3a..4d03e59 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,9 +1,15 @@ import { WebPlugin } from '@capacitor/core'; -import type { PrinterPlugin } from './definitions'; +import type { PrinterPlugin, PrintFileOptions, PrintWebViewOptions } from './definitions'; export class PrinterWeb extends WebPlugin implements PrinterPlugin { - async printWebView(): Promise { - console.log('Printing web view...'); + async printFile(options: PrintFileOptions): Promise { + void options; + throw this.unavailable('printFile is not available on the web.'); + } + + async printWebView(options?: PrintWebViewOptions): Promise { + void options; + window.print(); } } From 824cc75d395fd0cf661520c6f7136b74b33f6cf0 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 23 Jul 2026 09:52:45 +0900 Subject: [PATCH 2/3] fix: wait for print source release --- README.md | 3 ++ .../plugin/printer/PrinterPlugin.java | 34 ++++++++++++++----- src/definitions.ts | 3 ++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 83e01c9..b31cc81 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ printFile(options: PrintFileOptions) => Promise Present the printing user interface to print a file. +The promise settles after the operating system no longer needs the source +file, so the file can be safely deleted in a `finally` block. + Only available on Android and iOS. | Param | Type | diff --git a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java index 7254479..49458ca 100644 --- a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java +++ b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java @@ -82,8 +82,7 @@ private void printImage(PluginCall call, Uri uri, String jobName) { try { PrintHelper printHelper = new PrintHelper(getContext()); printHelper.setScaleMode(PrintHelper.SCALE_MODE_FIT); - printHelper.printBitmap(jobName, uri); - call.resolve(); + printHelper.printBitmap(jobName, uri, () -> call.resolve()); } catch (FileNotFoundException | SecurityException exception) { call.reject("Unable to read file: " + exception.getLocalizedMessage(), exception); } @@ -103,8 +102,7 @@ private void printPdf(PluginCall call, Uri uri, String jobName) { return; } - printManager.print(jobName, new PdfPrintDocumentAdapter(uri, jobName), new PrintAttributes.Builder().build()); - call.resolve(); + printManager.print(jobName, new PdfPrintDocumentAdapter(uri, jobName, call), new PrintAttributes.Builder().build()); } private InputStream openInputStream(Uri uri) throws FileNotFoundException { @@ -139,10 +137,13 @@ private final class PdfPrintDocumentAdapter extends PrintDocumentAdapter { private final Uri uri; private final String name; + private final PluginCall call; + private volatile String failureMessage; - private PdfPrintDocumentAdapter(Uri uri, String name) { + private PdfPrintDocumentAdapter(Uri uri, String name, PluginCall call) { this.uri = uri; this.name = name; + this.call = call; } @Override @@ -168,7 +169,9 @@ public void onWrite( CancellationSignal cancellationSignal, WriteResultCallback callback ) { + failureMessage = null; new Thread(() -> { + boolean cancelled = false; try ( InputStream input = openInputStream(uri); OutputStream output = new FileOutputStream(destination.getFileDescriptor()) @@ -177,17 +180,32 @@ public void onWrite( int length; while ((length = input.read(buffer)) != -1) { if (cancellationSignal.isCanceled()) { - callback.onWriteCancelled(); - return; + cancelled = true; + break; } output.write(buffer, 0, length); } - callback.onWriteFinished(new android.print.PageRange[] { android.print.PageRange.ALL_PAGES }); } catch (IOException | SecurityException exception) { + failureMessage = exception.getLocalizedMessage(); callback.onWriteFailed(exception.getLocalizedMessage()); + return; + } + if (cancelled) { + callback.onWriteCancelled(); + } else { + callback.onWriteFinished(new android.print.PageRange[] { android.print.PageRange.ALL_PAGES }); } }) .start(); } + + @Override + public void onFinish() { + if (failureMessage == null) { + call.resolve(); + } else { + call.reject("Unable to print file: " + failureMessage); + } + } } } diff --git a/src/definitions.ts b/src/definitions.ts index deeecb5..aaacd4c 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -2,6 +2,9 @@ export interface PrinterPlugin { /** * Present the printing user interface to print a file. * + * The promise settles after the operating system no longer needs the source + * file, so the file can be safely deleted in a `finally` block. + * * Only available on Android and iOS. */ printFile(options: PrintFileOptions): Promise; From 1b4266e012cf0c499bf339293669e1f3d6e6017b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 23 Jul 2026 10:05:51 +0900 Subject: [PATCH 3/3] fix: address printer review feedback --- README.md | 8 ++++---- .../jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java | 8 ++++++-- ios/Sources/PrinterPlugin/PrinterPlugin.swift | 7 ++++++- src/definitions.ts | 3 ++- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b31cc81..a3d522f 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,10 @@ Present the printing user interface to print the web view content. #### PrintFileOptions -| Prop | Type | Description | -| -------------- | ------------------- | -------------------------------------------------------------------------- | -| **`path`** | string | The path to the file. Both file paths and file/content URLs are supported. | -| **`mimeType`** | string | The MIME type of the file. Only used on Android. | +| Prop | Type | Description | +| -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **`path`** | string | The path to the file. Android supports file paths, `file://` URLs, and `content://` URLs. iOS supports file paths and local `file://` URLs. | +| **`mimeType`** | string | The MIME type of the file. Only used on Android. | #### PrintOptions diff --git a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java index 49458ca..08a50c4 100644 --- a/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java +++ b/android/src/main/java/jp/rdlabo/capacitor/plugin/printer/PrinterPlugin.java @@ -43,7 +43,7 @@ public void printFile(PluginCall call) { Uri uri = toUri(path); String jobName = getFileName(uri); - String normalizedMimeType = mimeType.toLowerCase(Locale.ROOT); + String normalizedMimeType = mimeType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); getActivity().runOnUiThread(() -> { if (isSupportedImageMimeType(normalizedMimeType)) { printImage(call, uri, jobName); @@ -70,7 +70,7 @@ public void printWebView(PluginCall call) { return; } - String jobName = call.getString("name", DEFAULT_JOB_NAME); + String jobName = normalizeJobName(call.getString("name")); PrintDocumentAdapter printAdapter = webView.createPrintDocumentAdapter(jobName); printManager.print(jobName, printAdapter, new PrintAttributes.Builder().build()); @@ -126,6 +126,10 @@ private static String getFileName(Uri uri) { return fileName == null || fileName.trim().isEmpty() ? DEFAULT_JOB_NAME : fileName; } + private static String normalizeJobName(String name) { + return name == null || name.trim().isEmpty() ? DEFAULT_JOB_NAME : name.trim(); + } + private static boolean isSupportedImageMimeType(String mimeType) { return switch (mimeType) { case "image/gif", "image/heic", "image/heif", "image/jpeg", "image/png" -> true; diff --git a/ios/Sources/PrinterPlugin/PrinterPlugin.swift b/ios/Sources/PrinterPlugin/PrinterPlugin.swift index 9f061d7..9c82805 100644 --- a/ios/Sources/PrinterPlugin/PrinterPlugin.swift +++ b/ios/Sources/PrinterPlugin/PrinterPlugin.swift @@ -58,7 +58,12 @@ public class PrinterPlugin: CAPPlugin, CAPBridgedPlugin { let printController = UIPrintInteractionController.shared let printInfo = UIPrintInfo(dictionary: nil) printInfo.outputType = .general - printInfo.jobName = call.getString("name") ?? "Document" + let requestedName = call.getString("name")?.trimmingCharacters(in: .whitespacesAndNewlines) + if let requestedName, !requestedName.isEmpty { + printInfo.jobName = requestedName + } else { + printInfo.jobName = "Document" + } printController.printInfo = printInfo printController.printFormatter = webView.viewPrintFormatter() self.present(printController, call: call) diff --git a/src/definitions.ts b/src/definitions.ts index aaacd4c..9f5ef4e 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -17,7 +17,8 @@ export interface PrinterPlugin { export interface PrintFileOptions { /** - * The path to the file. Both file paths and file/content URLs are supported. + * The path to the file. Android supports file paths, `file://` URLs, and + * `content://` URLs. iOS supports file paths and local `file://` URLs. */ path: string;