support share-to-print of images and PDFs

Make it easier for apps to print to any supported printer via a normal
share intent. Provides appropriate content resolution to PrintManager
for printing to any installed and enabled Print Service. Image content
is downscaled to a good DPI for preview (screen DPI) or delivery (300
DPI). Fit vs Fill options are activated by the user's landscape vs
portrait print attribute selection. Finally, Photos default to a
locale-specific default photo media size.

Test: Share any image or PDF then select "Print"
Change-Id: Iccaf987b2754bdb7a723ad5e94d414c1f33f5ef2
Signed-off-by: Glade Diviney <mopriadevteam@gmail.com>
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 23f290b..47c3077 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -67,5 +67,29 @@
             android:exported="true"
             android:configChanges="orientation|keyboardHidden|screenSize"
             android:permission="android.permission.START_PRINT_SERVICE_CONFIG_ACTIVITY" />
+
+        <activity
+            android:name="com.android.bips.ImagePrintActivity"
+            android:label="@string/print"
+            android:theme="@android:style/Theme.Translucent.NoTitleBar"
+            android:configChanges="keyboardHidden|orientation|screenSize">
+            <intent-filter>
+                <action android:name="android.intent.action.SEND" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="image/*" />
+            </intent-filter>
+        </activity>
+
+        <activity
+            android:name="com.android.bips.PdfPrintActivity"
+            android:label="@string/print"
+            android:theme="@android:style/Theme.Translucent.NoTitleBar"
+            android:configChanges="keyboardHidden|orientation|screenSize">
+            <intent-filter>
+                <action android:name="android.intent.action.SEND" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="application/pdf" />
+            </intent-filter>
+        </activity>
     </application>
 </manifest>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 0a93da9..46b5ce4 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -115,4 +115,7 @@
     <string name="wifi_direct_permission_rationale">Default Print Service needs location permission to find nearby devices.</string>
     <!-- Button label in a notification or dialog. This button leads to a request to grant permissions [CHAR LIMIT=20] -->
     <string name="fix">Review permission</string>
+
+    <!-- Share-to-print label [CHAR LIMIT=20] -->
+    <string name="print">Print</string>
 </resources>
diff --git a/src/com/android/bips/ImagePrintActivity.java b/src/com/android/bips/ImagePrintActivity.java
new file mode 100644
index 0000000..ee9834e
--- /dev/null
+++ b/src/com/android/bips/ImagePrintActivity.java
@@ -0,0 +1,306 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.bips;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.ColorMatrix;
+import android.graphics.ColorMatrixColorFilter;
+import android.graphics.Paint;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.print.PageRange;
+import android.print.PrintAttributes;
+import android.print.PrintDocumentAdapter;
+import android.print.PrintDocumentInfo;
+import android.print.PrintJob;
+import android.print.PrintManager;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.webkit.URLUtil;
+import android.widget.Toast;
+
+import com.android.bips.jni.MediaSizes;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Activity to receive share-to-print intents for images.
+ */
+public class ImagePrintActivity extends Activity {
+    private static final String TAG = ImagePrintActivity.class.getSimpleName();
+    private static final boolean DEBUG = false;
+    private static final int PRINT_DPI = 300;
+    private static final PrintAttributes.MediaSize DEFAULT_PHOTO_MEDIA =
+            PrintAttributes.MediaSize.NA_INDEX_4X6;
+
+    /** Countries where A5 is a more common photo media size. */
+    private static final String[] ISO_A5_COUNTRY_CODES = {
+        "IQ", "SY", "YE", "VN", "MA"
+    };
+
+    private CancellationSignal mCancellationSignal = new CancellationSignal();
+    private String mJobName;
+    private Bitmap mBitmap;
+    private DisplayMetrics mDisplayMetrics = new DisplayMetrics();
+    private Runnable mOnBitmapLoaded = null;
+    private AsyncTask<?, ?, ?> mTask = null;
+    private PrintJob mPrintJob;
+    private Bitmap mGrayscaleBitmap;
+    private PrintAttributes.MediaSize mDefaultMediaSize = null;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        String action = getIntent().getAction();
+        Uri contentUri = null;
+        if (Intent.ACTION_SEND.equals(action)) {
+            contentUri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM);
+        } else if (Intent.ACTION_VIEW.equals(action)) {
+            contentUri = getIntent().getData();
+        }
+        if (contentUri == null) {
+            finish();
+        }
+        getWindowManager().getDefaultDisplay().getMetrics(mDisplayMetrics);
+        mJobName = URLUtil.guessFileName(getIntent().getStringExtra(Intent.EXTRA_TEXT), null,
+                getIntent().resolveType(this));
+
+        if (DEBUG) Log.d(TAG, "onCreate() uri=" + contentUri + " jobName=" + mJobName);
+
+        // Load the bitmap while we start the print
+        mTask = new LoadBitmapTask().execute(contentUri);
+    }
+
+    /**
+     * A background task to load the bitmap and start the print job.
+     */
+    private class LoadBitmapTask extends AsyncTask<Uri, Boolean, Bitmap> {
+        @Override
+        protected Bitmap doInBackground(Uri... uris) {
+            if (DEBUG) Log.d(TAG, "Loading bitmap from stream");
+            BitmapFactory.Options options = new BitmapFactory.Options();
+            options.inJustDecodeBounds = true;
+            loadBitmap(uris[0], options);
+            if (options.outWidth <= 0 || options.outHeight <= 0) {
+                Log.w(TAG, "Failed to load bitmap");
+                return null;
+            }
+            if (mCancellationSignal.isCanceled()) {
+                return null;
+            } else {
+                // Publish progress and load for real
+                publishProgress(options.outHeight > options.outWidth);
+                options.inJustDecodeBounds = false;
+                return loadBitmap(uris[0], options);
+            }
+        }
+
+        /**
+         * Return a bitmap as loaded from {@param contentUri} using {@param options}.
+         */
+        private Bitmap loadBitmap(Uri contentUri, BitmapFactory.Options options) {
+            try (InputStream inputStream = getContentResolver().openInputStream(contentUri)) {
+                return BitmapFactory.decodeStream(inputStream, null, options);
+            } catch (IOException e) {
+                Log.w(TAG, "Failed to load bitmap", e);
+                return null;
+            }
+        }
+
+        @Override
+        protected void onProgressUpdate(Boolean... values) {
+            // Once we have a portrait/landscape determination, launch the print job
+            boolean isPortrait = values[0];
+            if (DEBUG) Log.d(TAG, "startPrint(portrait=" + isPortrait + ")");
+            PrintManager printManager = (PrintManager) getSystemService(Context.PRINT_SERVICE);
+            if (printManager == null) {
+                finish();
+                return;
+            }
+
+            PrintAttributes printAttributes = new PrintAttributes.Builder()
+                    .setMediaSize(isPortrait ? getLocaleDefaultMediaSize() :
+                            getLocaleDefaultMediaSize().asLandscape())
+                    .setColorMode(PrintAttributes.COLOR_MODE_COLOR)
+                    .build();
+            mPrintJob = printManager.print(mJobName, new ImageAdapter(), printAttributes);
+        }
+
+        @Override
+        protected void onPostExecute(Bitmap bitmap) {
+            if (mCancellationSignal.isCanceled()) {
+                if (DEBUG) Log.d(TAG, "LoadBitmapTask cancelled");
+            } else if (bitmap == null) {
+                if (mPrintJob != null) {
+                    mPrintJob.cancel();
+                }
+                Toast.makeText(ImagePrintActivity.this, R.string.unreadable_input,
+                    Toast.LENGTH_LONG).show();
+                finish();
+            } else {
+                if (DEBUG) Log.d(TAG, "LoadBitmapTask complete");
+                mBitmap = bitmap;
+                if (mOnBitmapLoaded != null) {
+                    mOnBitmapLoaded.run();
+                }
+            }
+        }
+    }
+
+    private PrintAttributes.MediaSize getLocaleDefaultMediaSize() {
+        if (mDefaultMediaSize == null) {
+            String country = getResources().getConfiguration().getLocales().get(0).getCountry();
+            Set<String> a5Countries = new HashSet<>(Arrays.asList(ISO_A5_COUNTRY_CODES));
+            if (Locale.JAPAN.getCountry().equals(country)) {
+                // Photo L is a more common media size in Japan
+                mDefaultMediaSize = new PrintAttributes.MediaSize(MediaSizes.OE_PHOTO_L,
+                        getString(R.string.media_size_l), 3500, 5000);
+            } else if (a5Countries.contains(country)) {
+                mDefaultMediaSize = PrintAttributes.MediaSize.ISO_A5;
+            } else {
+                mDefaultMediaSize = DEFAULT_PHOTO_MEDIA;
+            }
+        }
+        return mDefaultMediaSize;
+    }
+
+    @Override
+    protected void onDestroy() {
+        if (DEBUG) Log.d(TAG, "onDestroy()");
+        mCancellationSignal.cancel();
+        if (mTask != null) {
+            mTask.cancel(true);
+            mTask = null;
+        }
+        if (mBitmap != null) {
+            mBitmap.recycle();
+            mBitmap = null;
+        }
+        if (mGrayscaleBitmap != null) {
+            mGrayscaleBitmap.recycle();
+            mGrayscaleBitmap = null;
+        }
+        super.onDestroy();
+    }
+
+    /**
+     * An adapter that converts the image to PDF format as requested by the print system
+     */
+    private class ImageAdapter extends PrintDocumentAdapter {
+        private PrintAttributes mAttributes;
+        private int mDpi;
+
+        @Override
+        public void onLayout(PrintAttributes oldAttributes, PrintAttributes newAttributes,
+                CancellationSignal cancellationSignal, LayoutResultCallback callback,
+                Bundle bundle) {
+            if (DEBUG) Log.d(TAG, "onLayout() attrs=" + newAttributes);
+
+            if (mBitmap == null) {
+                if (DEBUG) Log.d(TAG, "waiting for bitmap...");
+                // Try again when bitmap has arrived
+                mOnBitmapLoaded = () -> onLayout(oldAttributes, newAttributes, cancellationSignal,
+                    callback, bundle);
+                return;
+            }
+
+            int oldDpi = mDpi;
+            mAttributes = newAttributes;
+
+            // Calculate required DPI (print or display)
+            if (bundle.getBoolean(EXTRA_PRINT_PREVIEW, false)) {
+                PrintAttributes.MediaSize mediaSize = mAttributes.getMediaSize();
+                mDpi = Math.min(
+                    mDisplayMetrics.widthPixels * 1000 / mediaSize.getWidthMils(),
+                    mDisplayMetrics.heightPixels * 1000 / mediaSize.getHeightMils());
+            } else {
+                mDpi = PRINT_DPI;
+            }
+
+            PrintDocumentInfo info = new PrintDocumentInfo.Builder(mJobName)
+                    .setContentType(PrintDocumentInfo.CONTENT_TYPE_PHOTO)
+                    .setPageCount(1)
+                    .build();
+            callback.onLayoutFinished(info, !newAttributes.equals(oldAttributes) || oldDpi != mDpi);
+        }
+
+        @Override
+        public void onWrite(PageRange[] pageRanges, ParcelFileDescriptor fileDescriptor,
+                CancellationSignal cancellationSignal, WriteResultCallback callback) {
+            if (DEBUG) Log.d(TAG, "onWrite()");
+            mCancellationSignal = cancellationSignal;
+
+            mTask = new ImageToPdfTask(ImagePrintActivity.this, getBitmap(mAttributes), mAttributes,
+                mDpi, cancellationSignal) {
+                @Override
+                protected void onPostExecute(Throwable throwable) {
+                    if (cancellationSignal.isCanceled()) {
+                        if (DEBUG) Log.d(TAG, "writeBitmap() cancelled");
+                        callback.onWriteCancelled();
+                    } else if (throwable != null) {
+                        Log.w(TAG, "Failed to write bitmap", throwable);
+                        callback.onWriteFailed(null);
+                    } else {
+                        if (DEBUG) Log.d(TAG, "Calling onWriteFinished");
+                        callback.onWriteFinished(new PageRange[] { PageRange.ALL_PAGES });
+                    }
+                    mTask = null;
+                }
+            }.execute(fileDescriptor);
+        }
+
+        @Override
+        public void onFinish() {
+            if (DEBUG) Log.d(TAG, "onFinish()");
+            finish();
+        }
+    }
+
+    /**
+     * Return an appropriate bitmap to use when rendering {@param attributes}.
+     */
+    private Bitmap getBitmap(PrintAttributes attributes) {
+        if (attributes.getColorMode() == PrintAttributes.COLOR_MODE_MONOCHROME) {
+            if (mGrayscaleBitmap == null) {
+                mGrayscaleBitmap = Bitmap.createBitmap(mBitmap.getWidth(), mBitmap.getHeight(),
+                    Bitmap.Config.ARGB_8888);
+                Canvas canvas = new Canvas(mGrayscaleBitmap);
+                Paint paint = new Paint();
+                ColorMatrix colorMatrix = new ColorMatrix();
+                colorMatrix.setSaturation(0);
+                paint.setColorFilter(new ColorMatrixColorFilter(colorMatrix));
+                canvas.drawBitmap(mBitmap, 0, 0, paint);
+            }
+            return mGrayscaleBitmap;
+        } else {
+            return mBitmap;
+        }
+    }
+}
diff --git a/src/com/android/bips/ImageToPdfTask.java b/src/com/android/bips/ImageToPdfTask.java
new file mode 100644
index 0000000..65e523c
--- /dev/null
+++ b/src/com/android/bips/ImageToPdfTask.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.bips;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.pdf.PdfDocument;
+import android.os.AsyncTask;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.print.PrintAttributes;
+import android.print.pdf.PrintedPdfDocument;
+import android.util.Log;
+
+import java.io.FileOutputStream;
+
+/**
+ * A background task that optimizes a {@link Bitmap}, renders it into a PDF, and delivers the PDF
+ * to a {@link ParcelFileDescriptor}.
+ */
+class ImageToPdfTask extends AsyncTask<ParcelFileDescriptor, Void, Throwable> {
+    private static final String TAG = ImageToPdfTask.class.getSimpleName();
+    private static final boolean DEBUG = false;
+    private static final float POINTS_PER_INCH = 72;
+
+    private final PrintedPdfDocument mDocument;
+    private final Bitmap mBitmap;
+    private final PrintAttributes mAttributes;
+    private final int mDpi;
+    private final CancellationSignal mCancellationSignal;
+
+    ImageToPdfTask(Context context, Bitmap bitmap, PrintAttributes attributes, int dpi,
+                   CancellationSignal cancellationSignal) {
+        mBitmap = bitmap;
+        mAttributes = attributes;
+        mCancellationSignal = cancellationSignal;
+        mDpi = dpi;
+        mDocument = new PrintedPdfDocument(context, mAttributes);
+    }
+
+    @Override
+    protected Throwable doInBackground(ParcelFileDescriptor... outputs) {
+        try (ParcelFileDescriptor output = outputs[0]) {
+            if (DEBUG) Log.d(TAG, "creating document at dpi=" + mDpi);
+            writeBitmapToDocument();
+            mCancellationSignal.throwIfCanceled();
+            if (DEBUG) Log.d(TAG, "writing to output stream");
+            mDocument.writeTo(new FileOutputStream(output.getFileDescriptor()));
+            mDocument.close();
+            if (DEBUG) Log.d(TAG, "finished sending");
+            return null;
+        } catch (Throwable t) {
+            return t;
+        }
+    }
+
+    /** Create a one-page PDF document containing the bitmap */
+    private void writeBitmapToDocument() {
+        PdfDocument.Page page = mDocument.startPage(1);
+        if (mAttributes.getMediaSize().isPortrait() == mBitmap.getWidth() < mBitmap.getHeight()) {
+            writeBitmapToPage(page, true);
+        } else {
+            // If user selects the opposite orientation, fit instead of fill.
+            writeBitmapToPage(page, false);
+        }
+        mDocument.finishPage(page);
+    }
+
+    private void writeBitmapToPage(PdfDocument.Page page, boolean fill) {
+        RectF extent = new RectF(page.getInfo().getContentRect());
+        float scale;
+        boolean rotate;
+        if (fill) {
+            // Fill the entire page with image data
+            scale = Math.max(extent.height() / POINTS_PER_INCH * mDpi / mBitmap.getHeight(),
+                extent.width() / POINTS_PER_INCH * mDpi / mBitmap.getWidth());
+            rotate = false;
+        } else {
+            // Scale and rotate the image to fit entirely on the page
+            scale = Math.min(extent.height() / POINTS_PER_INCH * mDpi / mBitmap.getWidth(),
+                extent.width() / POINTS_PER_INCH * mDpi / mBitmap.getHeight());
+            rotate = true;
+        }
+
+        if (scale >= 1) {
+            // Image will need to be scaled up
+            drawDirect(page, extent, fill, rotate);
+        } else {
+            // Scale image down to the size needed for printing
+            drawOptimized(page, extent, scale, rotate);
+        }
+    }
+
+    /**
+     * Render the source bitmap directly into the PDF
+     */
+    private void drawDirect(PdfDocument.Page page, RectF extent, boolean fill, boolean rotate) {
+        float scale;
+        if (fill) {
+            scale = Math.max(extent.height() / mBitmap.getHeight(),
+                extent.width() / mBitmap.getWidth());
+        } else {
+            scale = Math.min(extent.height() / mBitmap.getWidth(),
+                extent.width() / mBitmap.getHeight());
+        }
+
+        float offsetX = (extent.width() - mBitmap.getWidth() * scale) / 2;
+        float offsetY = (extent.height() - mBitmap.getHeight() * scale) / 2;
+
+        Matrix matrix = new Matrix();
+        if (rotate) {
+            matrix.postRotate(90, mBitmap.getWidth() / 2, mBitmap.getHeight() / 2);
+        }
+        matrix.postScale(scale, scale);
+        matrix.postTranslate(offsetX, offsetY);
+        page.getCanvas().clipRect(extent);
+        page.getCanvas().drawBitmap(mBitmap, matrix, new Paint(Paint.FILTER_BITMAP_FLAG));
+    }
+
+    /**
+     * Scale down the bitmap to specific DPI to reduce delivered PDF size
+     */
+    private void drawOptimized(PdfDocument.Page page, RectF extent, float scale, boolean rotate) {
+        float targetWidth = (extent.width() / POINTS_PER_INCH * mDpi);
+        float targetHeight = (extent.height() / POINTS_PER_INCH * mDpi);
+        float offsetX = ((targetWidth / scale) - mBitmap.getWidth()) / 2;
+        float offsetY = ((targetHeight / scale) - mBitmap.getHeight()) / 2;
+
+        Bitmap targetBitmap = Bitmap.createBitmap((int) targetWidth, (int) targetHeight,
+                Bitmap.Config.ARGB_8888);
+        Canvas bitmapCanvas = new Canvas(targetBitmap);
+        Matrix matrix = new Matrix();
+        matrix.postScale(scale, scale);
+        if (rotate) {
+            matrix.postRotate(90, targetWidth / 2, targetHeight / 2);
+        }
+        bitmapCanvas.setMatrix(matrix);
+        bitmapCanvas.drawBitmap(mBitmap, offsetX, offsetY, new Paint(Paint.FILTER_BITMAP_FLAG));
+        page.getCanvas().drawBitmap(targetBitmap, null, extent, null);
+        targetBitmap.recycle();
+    }
+}
diff --git a/src/com/android/bips/PdfPrintActivity.java b/src/com/android/bips/PdfPrintActivity.java
new file mode 100644
index 0000000..4463fae
--- /dev/null
+++ b/src/com/android/bips/PdfPrintActivity.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.bips;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.print.PageRange;
+import android.print.PrintAttributes;
+import android.print.PrintDocumentAdapter;
+import android.print.PrintDocumentInfo;
+import android.print.PrintManager;
+import android.util.Log;
+import android.webkit.URLUtil;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Activity to receive share-to-print intents for PDF documents.
+ */
+public class PdfPrintActivity extends Activity {
+    private static final String TAG = PdfPrintActivity.class.getSimpleName();
+    private static final boolean DEBUG = false;
+
+    private CancellationSignal mCancellationSignal;
+    private String mJobName;
+    Uri mContentUri = null;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        String action = getIntent().getAction();
+        if (Intent.ACTION_SEND.equals(action)) {
+            mContentUri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM);
+        } else if (Intent.ACTION_VIEW.equals(action)) {
+            mContentUri = getIntent().getData();
+        }
+        if (mContentUri == null) {
+            finish();
+        }
+        mJobName = URLUtil.guessFileName(getIntent().getStringExtra(Intent.EXTRA_TEXT), null,
+            getIntent().resolveType(this));
+        if (DEBUG) Log.d(TAG, "onCreate() uri=" + mContentUri + " jobName=" + mJobName);
+
+        PrintManager printManager = (PrintManager) getSystemService(Context.PRINT_SERVICE);
+        if (printManager == null) {
+            finish();
+            return;
+        }
+
+        PrintAttributes printAttributes = new PrintAttributes.Builder()
+                .setColorMode(PrintAttributes.COLOR_MODE_COLOR)
+                .build();
+        printManager.print(mJobName, new PdfAdapter(), printAttributes);
+    }
+
+    @Override
+    protected void onDestroy() {
+        if (DEBUG) Log.d(TAG, "onDestroy()");
+        if (mCancellationSignal != null) {
+            mCancellationSignal.cancel();
+        }
+        super.onDestroy();
+    }
+
+    private class PdfAdapter extends PrintDocumentAdapter {
+        @Override
+        public void onLayout(PrintAttributes oldAttributes, PrintAttributes newAttributes,
+                CancellationSignal cancellationSignal, LayoutResultCallback callback,
+                Bundle bundle) {
+            if (DEBUG) Log.d(TAG, "onLayout() attrs=" + newAttributes);
+
+            PrintDocumentInfo info = new PrintDocumentInfo.Builder(mJobName)
+                    .setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
+                    .setPageCount(PrintDocumentInfo.PAGE_COUNT_UNKNOWN)
+                    .build();
+            callback.onLayoutFinished(info, false);
+        }
+
+        @Override
+        public void onWrite(PageRange[] pageRanges, ParcelFileDescriptor fileDescriptor,
+                CancellationSignal cancellationSignal, WriteResultCallback callback) {
+            if (DEBUG) Log.d(TAG, "onWrite()");
+            mCancellationSignal = cancellationSignal;
+            new PdfDeliverTask(fileDescriptor, callback).execute();
+        }
+
+        @Override
+        public void onFinish() {
+            if (DEBUG) Log.d(TAG, "onFinish()");
+            finish();
+        }
+    }
+
+    private class PdfDeliverTask extends AsyncTask<Void, Void, Void> {
+        ParcelFileDescriptor mDescriptor;
+        PrintDocumentAdapter.WriteResultCallback mCallback;
+
+        PdfDeliverTask(ParcelFileDescriptor descriptor,
+                       PrintDocumentAdapter.WriteResultCallback callback) {
+            mDescriptor = descriptor;
+            mCallback = callback;
+        }
+
+        @Override
+        protected Void doInBackground(Void... voids) {
+            try (InputStream in = getContentResolver().openInputStream(mContentUri)) {
+                if (in == null) {
+                    throw new IOException("Failed to open input stream");
+                }
+                try (OutputStream out = new FileOutputStream(mDescriptor.getFileDescriptor())) {
+                    byte[] buffer = new byte[10 * 1024];
+                    int length;
+                    while ((length = in.read(buffer)) >= 0 && !mCancellationSignal.isCanceled()) {
+                        out.write(buffer, 0, length);
+                    }
+                }
+                if (mCancellationSignal.isCanceled()) {
+                    mCallback.onWriteCancelled();
+                } else {
+                    mCallback.onWriteFinished(new PageRange[] { PageRange.ALL_PAGES });
+                }
+            } catch (IOException e) {
+                Log.w(TAG, "Failed to deliver content", e);
+                mCallback.onWriteFailed(e.getMessage());
+            }
+            return null;
+        }
+    }
+}
diff --git a/src/com/android/bips/discovery/MdnsDiscovery.java b/src/com/android/bips/discovery/MdnsDiscovery.java
index d3fd2f1..51c1781 100644
--- a/src/com/android/bips/discovery/MdnsDiscovery.java
+++ b/src/com/android/bips/discovery/MdnsDiscovery.java
@@ -107,7 +107,7 @@
 
         // Must be IPv4
         if (!(info.getHost() instanceof Inet4Address)) {
-            if (DEBUG) Log.d(TAG, "Not IPv4" + info);
+            if (DEBUG) Log.d(TAG, "Not IPv4 " + info.getHost());
             return null;
         }
 
diff --git a/src/com/android/bips/jni/MediaSizes.java b/src/com/android/bips/jni/MediaSizes.java
index 1ebdf21..4ec76f3 100644
--- a/src/com/android/bips/jni/MediaSizes.java
+++ b/src/com/android/bips/jni/MediaSizes.java
@@ -43,7 +43,7 @@
     private static final String OM_CARD = "om_card_54x86mm";
     private static final String JIS_B4 = "jis_b4_257x364mm";
     private static final String JIS_B5 = "jis_b5_182x257mm";
-    private static final String OE_PHOTO_L = "oe_photo-l_3.5x5in";
+    public static final String OE_PHOTO_L = "oe_photo-l_3.5x5in";
     private static final String NA_GOVT_LETTER = "na_govt-letter_8x10in";
 
     /** The backend string name for the default media size */