/* * Copyright (C) 2014 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.printspooler.model; import android.app.ActivityManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.drawable.BitmapDrawable; import android.net.Uri; import android.os.AsyncTask; import android.os.IBinder; import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.print.PrintAttributes; import android.print.PrintAttributes.MediaSize; import android.print.PrintAttributes.Margins; import android.print.PrintDocumentInfo; import android.util.ArrayMap; import android.util.Log; import android.view.View; import com.android.internal.annotations.GuardedBy; import com.android.printspooler.renderer.IPdfRenderer; import com.android.printspooler.renderer.PdfManipulationService; import com.android.printspooler.util.BitmapSerializeUtils; import dalvik.system.CloseGuard; import libcore.io.IoUtils; import java.io.IOException; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; public final class PageContentRepository { private static final String LOG_TAG = "PageContentRepository"; private static final boolean DEBUG = false; private static final int INVALID_PAGE_INDEX = -1; private static final int STATE_CLOSED = 0; private static final int STATE_OPENED = 1; private static final int STATE_DESTROYED = 2; private static final int BYTES_PER_PIXEL = 4; private static final int BYTES_PER_MEGABYTE = 1048576; private final CloseGuard mCloseGuard = CloseGuard.get(); private final AsyncRenderer mRenderer; private RenderSpec mLastRenderSpec; private int mScheduledPreloadFirstShownPage = INVALID_PAGE_INDEX; private int mScheduledPreloadLastShownPage = INVALID_PAGE_INDEX; private int mState; public interface OnPageContentAvailableCallback { public void onPageContentAvailable(BitmapDrawable content); } public PageContentRepository(Context context) { mRenderer = new AsyncRenderer(context); mState = STATE_CLOSED; if (DEBUG) { Log.i(LOG_TAG, "STATE_CLOSED"); } mCloseGuard.open("destroy"); } public void open(ParcelFileDescriptor source, final OpenDocumentCallback callback) { throwIfNotClosed(); mState = STATE_OPENED; if (DEBUG) { Log.i(LOG_TAG, "STATE_OPENED"); } mRenderer.open(source, callback); } public void close(Runnable callback) { throwIfNotOpened(); mState = STATE_CLOSED; if (DEBUG) { Log.i(LOG_TAG, "STATE_CLOSED"); } mRenderer.close(callback); } public void destroy(final Runnable callback) { if (mState == STATE_OPENED) { close(new Runnable() { @Override public void run() { destroy(callback); } }); return; } mState = STATE_DESTROYED; if (DEBUG) { Log.i(LOG_TAG, "STATE_DESTROYED"); } mRenderer.destroy(); if (callback != null) { callback.run(); } } public void startPreload(int firstShownPage, int lastShownPage) { // If we do not have a render spec we have no clue what size the // preloaded bitmaps should be, so just take a note for what to do. if (mLastRenderSpec == null) { mScheduledPreloadFirstShownPage = firstShownPage; mScheduledPreloadLastShownPage = lastShownPage; } else if (mState == STATE_OPENED) { mRenderer.startPreload(firstShownPage, lastShownPage, mLastRenderSpec); } } public void stopPreload() { mRenderer.stopPreload(); } public int getFilePageCount() { return mRenderer.getPageCount(); } public PageContentProvider acquirePageContentProvider(int pageIndex, View owner) { throwIfDestroyed(); if (DEBUG) { Log.i(LOG_TAG, "Acquiring provider for page: " + pageIndex); } return new PageContentProvider(pageIndex, owner); } public void releasePageContentProvider(PageContentProvider provider) { throwIfDestroyed(); if (DEBUG) { Log.i(LOG_TAG, "Releasing provider for page: " + provider.mPageIndex); } provider.cancelLoad(); } @Override protected void finalize() throws Throwable { try { if (mState != STATE_DESTROYED) { mCloseGuard.warnIfOpen(); destroy(null); } } finally { super.finalize(); } } private void throwIfNotOpened() { if (mState != STATE_OPENED) { throw new IllegalStateException("Not opened"); } } private void throwIfNotClosed() { if (mState != STATE_CLOSED) { throw new IllegalStateException("Not closed"); } } private void throwIfDestroyed() { if (mState == STATE_DESTROYED) { throw new IllegalStateException("Destroyed"); } } public final class PageContentProvider { private final int mPageIndex; private View mOwner; public PageContentProvider(int pageIndex, View owner) { mPageIndex = pageIndex; mOwner = owner; } public View getOwner() { return mOwner; } public int getPageIndex() { return mPageIndex; } public void getPageContent(RenderSpec renderSpec, OnPageContentAvailableCallback callback) { throwIfDestroyed(); mLastRenderSpec = renderSpec; // We tired to preload but didn't know the bitmap size, now // that we know let us do the work. if (mScheduledPreloadFirstShownPage != INVALID_PAGE_INDEX && mScheduledPreloadLastShownPage != INVALID_PAGE_INDEX) { startPreload(mScheduledPreloadFirstShownPage, mScheduledPreloadLastShownPage); mScheduledPreloadFirstShownPage = INVALID_PAGE_INDEX; mScheduledPreloadLastShownPage = INVALID_PAGE_INDEX; } if (mState == STATE_OPENED) { mRenderer.renderPage(mPageIndex, renderSpec, callback); } else { mRenderer.getCachedPage(mPageIndex, renderSpec, callback); } } void cancelLoad() { throwIfDestroyed(); if (mState == STATE_OPENED) { mRenderer.cancelRendering(mPageIndex); } } } private static final class PageContentLruCache { private final LinkedHashMap mRenderedPages = new LinkedHashMap<>(); private final int mMaxSizeInBytes; private int mSizeInBytes; public PageContentLruCache(int maxSizeInBytes) { mMaxSizeInBytes = maxSizeInBytes; } public RenderedPage getRenderedPage(int pageIndex) { return mRenderedPages.get(pageIndex); } public RenderedPage removeRenderedPage(int pageIndex) { RenderedPage page = mRenderedPages.remove(pageIndex); if (page != null) { mSizeInBytes -= page.getSizeInBytes(); } return page; } public RenderedPage putRenderedPage(int pageIndex, RenderedPage renderedPage) { RenderedPage oldRenderedPage = mRenderedPages.remove(pageIndex); if (oldRenderedPage != null) { if (!oldRenderedPage.renderSpec.equals(renderedPage.renderSpec)) { throw new IllegalStateException("Wrong page size"); } } else { final int contentSizeInBytes = renderedPage.getSizeInBytes(); if (mSizeInBytes + contentSizeInBytes > mMaxSizeInBytes) { throw new IllegalStateException("Client didn't free space"); } mSizeInBytes += contentSizeInBytes; } return mRenderedPages.put(pageIndex, renderedPage); } public void invalidate() { for (Map.Entry entry : mRenderedPages.entrySet()) { entry.getValue().state = RenderedPage.STATE_SCRAP; } } public RenderedPage removeLeastNeeded() { if (mRenderedPages.isEmpty()) { return null; } // First try to remove a rendered page that holds invalidated // or incomplete content, i.e. its render spec is null. for (Map.Entry entry : mRenderedPages.entrySet()) { RenderedPage renderedPage = entry.getValue(); if (renderedPage.state == RenderedPage.STATE_SCRAP) { Integer pageIndex = entry.getKey(); mRenderedPages.remove(pageIndex); mSizeInBytes -= renderedPage.getSizeInBytes(); return renderedPage; } } // If all rendered pages contain rendered content, then use the oldest. final int pageIndex = mRenderedPages.eldest().getKey(); RenderedPage renderedPage = mRenderedPages.remove(pageIndex); mSizeInBytes -= renderedPage.getSizeInBytes(); return renderedPage; } public int getSizeInBytes() { return mSizeInBytes; } public int getMaxSizeInBytes() { return mMaxSizeInBytes; } public void clear() { Iterator> iterator = mRenderedPages.entrySet().iterator(); while (iterator.hasNext()) { iterator.next(); iterator.remove(); } } } public static final class RenderSpec { final int bitmapWidth; final int bitmapHeight; final PrintAttributes printAttributes = new PrintAttributes.Builder().build(); public RenderSpec(int bitmapWidth, int bitmapHeight, MediaSize mediaSize, Margins minMargins) { this.bitmapWidth = bitmapWidth; this.bitmapHeight = bitmapHeight; printAttributes.setMediaSize(mediaSize); printAttributes.setMinMargins(minMargins); } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } RenderSpec other = (RenderSpec) obj; if (bitmapHeight != other.bitmapHeight) { return false; } if (bitmapWidth != other.bitmapWidth) { return false; } if (printAttributes != null) { if (!printAttributes.equals(other.printAttributes)) { return false; } } else if (other.printAttributes != null) { return false; } return true; } public boolean hasSameSize(RenderedPage page) { Bitmap bitmap = page.content.getBitmap(); return bitmap.getWidth() == bitmapWidth && bitmap.getHeight() == bitmapHeight; } @Override public int hashCode() { int result = bitmapWidth; result = 31 * result + bitmapHeight; result = 31 * result + (printAttributes != null ? printAttributes.hashCode() : 0); return result; } } private static final class RenderedPage { public static final int STATE_RENDERED = 0; public static final int STATE_RENDERING = 1; public static final int STATE_SCRAP = 2; final BitmapDrawable content; RenderSpec renderSpec; int state = STATE_SCRAP; RenderedPage(BitmapDrawable content) { this.content = content; } public int getSizeInBytes() { return content.getBitmap().getByteCount(); } public void erase() { content.getBitmap().eraseColor(Color.WHITE); } } private static final class AsyncRenderer implements ServiceConnection { private final Object mLock = new Object(); private final Context mContext; private final PageContentLruCache mPageContentCache; private final ArrayMap mPageToRenderTaskMap = new ArrayMap<>(); private int mPageCount = PrintDocumentInfo.PAGE_COUNT_UNKNOWN; @GuardedBy("mLock") private IPdfRenderer mRenderer; private OpenTask mOpenTask; private boolean mBoundToService; private boolean mDestroyed; public AsyncRenderer(Context context) { mContext = context; ActivityManager activityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE); final int cacheSizeInBytes = activityManager.getMemoryClass() * BYTES_PER_MEGABYTE / 4; mPageContentCache = new PageContentLruCache(cacheSizeInBytes); } @Override public void onServiceConnected(ComponentName name, IBinder service) { synchronized (mLock) { mRenderer = IPdfRenderer.Stub.asInterface(service); mLock.notifyAll(); } } @Override public void onServiceDisconnected(ComponentName name) { synchronized (mLock) { mRenderer = null; } } public void open(ParcelFileDescriptor source, OpenDocumentCallback callback) { // Opening a new document invalidates the cache as it has pages // from the last document. We keep the cache even when the document // is closed to show pages while the other side is writing the new // document. mPageContentCache.invalidate(); mOpenTask = new OpenTask(source, callback); mOpenTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } public void close(final Runnable callback) { cancelAllRendering(); if (mOpenTask != null) { mOpenTask.cancel(); } new AsyncTask() { @Override protected void onPreExecute() { if (mDestroyed) { cancel(true); return; } } @Override protected Void doInBackground(Void... params) { synchronized (mLock) { try { if (mRenderer != null) { mRenderer.closeDocument(); } } catch (RemoteException re) { /* ignore */ } } return null; } @Override public void onPostExecute(Void result) { mPageCount = PrintDocumentInfo.PAGE_COUNT_UNKNOWN; if (callback != null) { callback.run(); } } }.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } public void destroy() { if (mBoundToService) { mBoundToService = false; mContext.unbindService(AsyncRenderer.this); } mPageContentCache.invalidate(); mPageContentCache.clear(); mDestroyed = true; } public void startPreload(int firstShownPage, int lastShownPage, RenderSpec renderSpec) { if (DEBUG) { Log.i(LOG_TAG, "Preloading pages around [" + firstShownPage + "-" + lastShownPage + "]"); } final int bitmapSizeInBytes = renderSpec.bitmapWidth * renderSpec.bitmapHeight * BYTES_PER_PIXEL; final int maxCachedPageCount = mPageContentCache.getMaxSizeInBytes() / bitmapSizeInBytes; final int halfPreloadCount = (maxCachedPageCount - (lastShownPage - firstShownPage)) / 2 - 1; final int excessFromStart; if (firstShownPage - halfPreloadCount < 0) { excessFromStart = halfPreloadCount - firstShownPage; } else { excessFromStart = 0; } final int excessFromEnd; if (lastShownPage + halfPreloadCount >= mPageCount) { excessFromEnd = (lastShownPage + halfPreloadCount) - mPageCount; } else { excessFromEnd = 0; } final int fromIndex = Math.max(firstShownPage - halfPreloadCount - excessFromEnd, 0); final int toIndex = Math.min(lastShownPage + halfPreloadCount + excessFromStart, mPageCount - 1); for (int i = fromIndex; i <= toIndex; i++) { renderPage(i, renderSpec, null); } } public void stopPreload() { final int taskCount = mPageToRenderTaskMap.size(); for (int i = 0; i < taskCount; i++) { RenderPageTask task = mPageToRenderTaskMap.valueAt(i); if (task.isPreload() && !task.isCancelled()) { task.cancel(true); } } } public int getPageCount() { return mPageCount; } public void getCachedPage(int pageIndex, RenderSpec renderSpec, OnPageContentAvailableCallback callback) { RenderedPage renderedPage = mPageContentCache.getRenderedPage(pageIndex); if (renderedPage != null && renderedPage.state == RenderedPage.STATE_RENDERED && renderedPage.renderSpec.equals(renderSpec)) { if (DEBUG) { Log.i(LOG_TAG, "Cache hit for page: " + pageIndex); } // Announce if needed. if (callback != null) { callback.onPageContentAvailable(renderedPage.content); } } } public void renderPage(int pageIndex, RenderSpec renderSpec, OnPageContentAvailableCallback callback) { // First, check if we have a rendered page for this index. RenderedPage renderedPage = mPageContentCache.getRenderedPage(pageIndex); if (renderedPage != null && renderedPage.state == RenderedPage.STATE_RENDERED) { // If we have rendered page with same constraints - done. if (renderedPage.renderSpec.equals(renderSpec)) { if (DEBUG) { Log.i(LOG_TAG, "Cache hit for page: " + pageIndex); } // Announce if needed. if (callback != null) { callback.onPageContentAvailable(renderedPage.content); } return; } else { // If the constraints changed, mark the page obsolete. renderedPage.state = RenderedPage.STATE_SCRAP; } } // Next, check if rendering this page is scheduled. RenderPageTask renderTask = mPageToRenderTaskMap.get(pageIndex); if (renderTask != null && !renderTask.isCancelled()) { // If not rendered and constraints same.... if (renderTask.mRenderSpec.equals(renderSpec)) { if (renderTask.mCallback != null) { // If someone else is already waiting for this page - bad state. if (callback != null && renderTask.mCallback != callback) { throw new IllegalStateException("Page rendering not cancelled"); } } else { // No callback means we are preloading so just let the argument // callback be attached to our work in progress. renderTask.mCallback = callback; } return; } else { // If not rendered and constraints changed - cancel rendering. renderTask.cancel(true); } } // Oh well, we will have work to do... renderTask = new RenderPageTask(pageIndex, renderSpec, callback); mPageToRenderTaskMap.put(pageIndex, renderTask); renderTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); } public void cancelRendering(int pageIndex) { RenderPageTask task = mPageToRenderTaskMap.get(pageIndex); if (task != null && !task.isCancelled()) { task.cancel(true); } } private void cancelAllRendering() { final int taskCount = mPageToRenderTaskMap.size(); for (int i = 0; i < taskCount; i++) { RenderPageTask task = mPageToRenderTaskMap.valueAt(i); if (!task.isCancelled()) { task.cancel(true); } } } private final class OpenTask extends AsyncTask { private final ParcelFileDescriptor mSource; private final OpenDocumentCallback mCallback; public OpenTask(ParcelFileDescriptor source, OpenDocumentCallback callback) { mSource = source; mCallback = callback; } @Override protected void onPreExecute() { if (mDestroyed) { cancel(true); return; } Intent intent = new Intent(PdfManipulationService.ACTION_GET_RENDERER); intent.setClass(mContext, PdfManipulationService.class); intent.setData(Uri.fromParts("fake-scheme", String.valueOf( AsyncRenderer.this.hashCode()), null)); mContext.bindService(intent, AsyncRenderer.this, Context.BIND_AUTO_CREATE); mBoundToService = true; } @Override protected Integer doInBackground(Void... params) { synchronized (mLock) { while (mRenderer == null && !isCancelled()) { try { mLock.wait(); } catch (InterruptedException ie) { /* ignore */ } } try { return mRenderer.openDocument(mSource); } catch (RemoteException re) { Log.e(LOG_TAG, "Cannot open PDF document"); return PdfManipulationService.ERROR_MALFORMED_PDF_FILE; } finally { // Close the fd as we passed it to another process // which took ownership. IoUtils.closeQuietly(mSource); } } } @Override public void onPostExecute(Integer pageCount) { switch (pageCount) { case PdfManipulationService.ERROR_MALFORMED_PDF_FILE: { mPageCount = PrintDocumentInfo.PAGE_COUNT_UNKNOWN; if (mCallback != null) { mCallback.onFailure(OpenDocumentCallback.ERROR_MALFORMED_PDF_FILE); } } break; case PdfManipulationService.ERROR_SECURE_PDF_FILE: { mPageCount = PrintDocumentInfo.PAGE_COUNT_UNKNOWN; if (mCallback != null) { mCallback.onFailure(OpenDocumentCallback.ERROR_SECURE_PDF_FILE); } } break; default: { mPageCount = pageCount; if (mCallback != null) { mCallback.onSuccess(); } } break; } mOpenTask = null; } @Override protected void onCancelled(Integer integer) { mOpenTask = null; } public void cancel() { cancel(true); synchronized(mLock) { mLock.notifyAll(); } } } private final class RenderPageTask extends AsyncTask { final int mPageIndex; final RenderSpec mRenderSpec; OnPageContentAvailableCallback mCallback; RenderedPage mRenderedPage; public RenderPageTask(int pageIndex, RenderSpec renderSpec, OnPageContentAvailableCallback callback) { mPageIndex = pageIndex; mRenderSpec = renderSpec; mCallback = callback; } @Override protected void onPreExecute() { mRenderedPage = mPageContentCache.getRenderedPage(mPageIndex); if (mRenderedPage != null && mRenderedPage.state == RenderedPage.STATE_RENDERED) { throw new IllegalStateException("Trying to render a rendered page"); } // Reuse bitmap for the page only if the right size. if (mRenderedPage != null && !mRenderSpec.hasSameSize(mRenderedPage)) { if (DEBUG) { Log.i(LOG_TAG, "Recycling bitmap for page: " + mPageIndex + " with different size."); } mPageContentCache.removeRenderedPage(mPageIndex); mRenderedPage = null; } final int bitmapSizeInBytes = mRenderSpec.bitmapWidth * mRenderSpec.bitmapHeight * BYTES_PER_PIXEL; // Try to find a bitmap to reuse. while (mRenderedPage == null) { // Fill the cache greedily. if (mPageContentCache.getSizeInBytes() <= 0 || mPageContentCache.getSizeInBytes() + bitmapSizeInBytes <= mPageContentCache.getMaxSizeInBytes()) { break; } RenderedPage renderedPage = mPageContentCache.removeLeastNeeded(); if (!mRenderSpec.hasSameSize(renderedPage)) { if (DEBUG) { Log.i(LOG_TAG, "Recycling bitmap for page: " + mPageIndex + " with different size."); } continue; } mRenderedPage = renderedPage; renderedPage.erase(); if (DEBUG) { Log.i(LOG_TAG, "Reused bitmap for page: " + mPageIndex + " cache size: " + mPageContentCache.getSizeInBytes() + " bytes"); } break; } if (mRenderedPage == null) { if (DEBUG) { Log.i(LOG_TAG, "Created bitmap for page: " + mPageIndex + " cache size: " + mPageContentCache.getSizeInBytes() + " bytes"); } Bitmap bitmap = Bitmap.createBitmap(mRenderSpec.bitmapWidth, mRenderSpec.bitmapHeight, Bitmap.Config.ARGB_8888); bitmap.eraseColor(Color.WHITE); BitmapDrawable content = new BitmapDrawable(mContext.getResources(), bitmap); mRenderedPage = new RenderedPage(content); } mRenderedPage.renderSpec = mRenderSpec; mRenderedPage.state = RenderedPage.STATE_RENDERING; mPageContentCache.putRenderedPage(mPageIndex, mRenderedPage); } @Override protected RenderedPage doInBackground(Void... params) { if (isCancelled()) { return mRenderedPage; } Bitmap bitmap = mRenderedPage.content.getBitmap(); ParcelFileDescriptor[] pipe = null; try { pipe = ParcelFileDescriptor.createPipe(); ParcelFileDescriptor source = pipe[0]; ParcelFileDescriptor destination = pipe[1]; mRenderer.renderPage(mPageIndex, bitmap.getWidth(), bitmap.getHeight(), mRenderSpec.printAttributes, destination); // We passed the file descriptor to the other side which took // ownership, so close our copy for the write to complete. destination.close(); BitmapSerializeUtils.readBitmapPixels(bitmap, source); } catch (IOException|RemoteException e) { Log.e(LOG_TAG, "Error rendering page:" + mPageIndex, e); } finally { IoUtils.closeQuietly(pipe[0]); IoUtils.closeQuietly(pipe[1]); } return mRenderedPage; } @Override public void onPostExecute(RenderedPage renderedPage) { if (DEBUG) { Log.i(LOG_TAG, "Completed rendering page: " + mPageIndex); } // This task is done. mPageToRenderTaskMap.remove(mPageIndex); // Take a note that the content is rendered. renderedPage.state = RenderedPage.STATE_RENDERED; // Announce success if needed. if (mCallback != null) { mCallback.onPageContentAvailable(renderedPage.content); } } @Override protected void onCancelled(RenderedPage renderedPage) { if (DEBUG) { Log.i(LOG_TAG, "Cancelled rendering page: " + mPageIndex); } // This task is done. mPageToRenderTaskMap.remove(mPageIndex); // If canceled before on pre-execute. if (renderedPage == null) { return; } // Take a note that the content is not rendered. renderedPage.state = RenderedPage.STATE_SCRAP; } public boolean isPreload() { return mCallback == null; } } } }