1package com.davemorrissey.labs.subscaleview.decoder;
2
3import android.app.ActivityManager;
4import android.content.ContentResolver;
5import android.content.Context;
6import android.content.pm.PackageManager;
7import android.content.res.AssetFileDescriptor;
8import android.content.res.AssetManager;
9import android.content.res.Resources;
10import android.graphics.Bitmap;
11import android.graphics.BitmapFactory;
12import android.graphics.BitmapRegionDecoder;
13import android.graphics.Point;
14import android.graphics.Rect;
15import android.net.Uri;
16import android.os.Build;
17import android.support.annotation.Keep;
18import android.text.TextUtils;
19import android.util.Log;
20
21import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
22
23import java.io.File;
24import java.io.FileFilter;
25import java.io.InputStream;
26import java.util.List;
27import java.util.Map;
28import java.util.concurrent.ConcurrentHashMap;
29import java.util.concurrent.Executor;
30import java.util.concurrent.Semaphore;
31import java.util.concurrent.atomic.AtomicBoolean;
32import java.util.concurrent.locks.ReadWriteLock;
33import java.util.concurrent.locks.ReentrantReadWriteLock;
34import java.util.regex.Pattern;
35
36import static android.content.Context.ACTIVITY_SERVICE;
37
38/**
39 * <p>
40 * An implementation of {@link ImageRegionDecoder} using a pool of {@link BitmapRegionDecoder}s,
41 * to provide true parallel loading of tiles. This is only effective if parallel loading has been
42 * enabled in the view by calling {@link com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView#setExecutor(Executor)}
43 * with a multi-threaded {@link Executor} instance.
44 * </p><p>
45 * One decoder is initialised when the class is initialised. This is enough to decode base layer tiles.
46 * Additional decoders are initialised when a subregion of the image is first requested, which indicates
47 * interaction with the view. Creation of additional encoders stops when {@link #allowAdditionalDecoder(int, long)}
48 * returns false. The default implementation takes into account the file size, number of CPU cores,
49 * low memory status and a hard limit of 4. Extend this class to customise this.
50 * </p><p>
51 * <b>WARNING:</b> This class is highly experimental and not proven to be stable on a wide range of
52 * devices. You are advised to test it thoroughly on all available devices, and code your app to use
53 * {@link SkiaImageRegionDecoder} on old or low powered devices you could not test.
54 * </p>
55 */
56public class SkiaPooledImageRegionDecoder implements ImageRegionDecoder {
57
58    private static final String TAG = SkiaPooledImageRegionDecoder.class.getSimpleName();
59
60    private static boolean debug = false;
61
62    private DecoderPool decoderPool = new DecoderPool();
63    private final ReadWriteLock decoderLock = new ReentrantReadWriteLock(true);
64
65    private static final String FILE_PREFIX = "file://";
66    private static final String ASSET_PREFIX = FILE_PREFIX + "/android_asset/";
67    private static final String RESOURCE_PREFIX = ContentResolver.SCHEME_ANDROID_RESOURCE + "://";
68
69    private final Bitmap.Config bitmapConfig;
70
71    private Context context;
72    private Uri uri;
73
74    private long fileLength = Long.MAX_VALUE;
75    private final Point imageDimensions = new Point(0, 0);
76    private final AtomicBoolean lazyInited = new AtomicBoolean(false);
77
78    @Keep
79    @SuppressWarnings("unused")
80    public SkiaPooledImageRegionDecoder() {
81        this(null);
82    }
83
84    @SuppressWarnings({"WeakerAccess", "SameParameterValue"})
85    public SkiaPooledImageRegionDecoder(Bitmap.Config bitmapConfig) {
86        Bitmap.Config globalBitmapConfig = SubsamplingScaleImageView.getPreferredBitmapConfig();
87        if (bitmapConfig != null) {
88            this.bitmapConfig = bitmapConfig;
89        } else if (globalBitmapConfig != null) {
90            this.bitmapConfig = globalBitmapConfig;
91        } else {
92            this.bitmapConfig = Bitmap.Config.RGB_565;
93        }
94    }
95
96    /**
97     * Controls logging of debug messages. All instances are affected.
98     * @param debug true to enable debug logging, false to disable.
99     */
100    @Keep
101    @SuppressWarnings("unused")
102    public static void setDebug(boolean debug) {
103        SkiaPooledImageRegionDecoder.debug = debug;
104    }
105
106    /**
107     * Initialises the decoder pool. This method creates one decoder on the current thread and uses
108     * it to decode the bounds, then spawns an independent thread to populate the pool with an
109     * additional three decoders. The thread will abort if {@link #recycle()} is called.
110     */
111    @Override
112    public Point init(final Context context, final Uri uri) throws Exception {
113        this.context = context;
114        this.uri = uri;
115        initialiseDecoder();
116        return this.imageDimensions;
117    }
118
119    /**
120     * Initialises extra decoders for as long as {@link #allowAdditionalDecoder(int, long)} returns
121     * true and the pool has not been recycled.
122     */
123    private void lazyInit() {
124        if (lazyInited.compareAndSet(false, true) && fileLength < Long.MAX_VALUE) {
125            debug("Starting lazy init of additional decoders");
126            Thread thread = new Thread() {
127                @Override
128                public void run() {
129                    while (decoderPool != null && allowAdditionalDecoder(decoderPool.size(), fileLength)) {
130                        // New decoders can be created while reading tiles but this read lock prevents
131                        // them being initialised while the pool is being recycled.
132                        try {
133                            if (decoderPool != null) {
134                                long start = System.currentTimeMillis();
135                                debug("Starting decoder");
136                                initialiseDecoder();
137                                long end = System.currentTimeMillis();
138                                debug("Started decoder, took " + (end - start) + "ms");
139                            }
140                        } catch (Exception e) {
141                            // A decoder has already been successfully created so we can ignore this
142                            debug("Failed to start decoder: " + e.getMessage());
143                        }
144                    }
145                }
146            };
147            thread.start();
148        }
149    }
150
151    /**
152     * Initialises a new {@link BitmapRegionDecoder} and adds it to the pool, unless the pool has
153     * been recycled while it was created.
154     */
155    private void initialiseDecoder() throws Exception {
156        String uriString = uri.toString();
157        BitmapRegionDecoder decoder;
158        long fileLength = Long.MAX_VALUE;
159        if (uriString.startsWith(RESOURCE_PREFIX)) {
160            Resources res;
161            String packageName = uri.getAuthority();
162            if (context.getPackageName().equals(packageName)) {
163                res = context.getResources();
164            } else {
165                PackageManager pm = context.getPackageManager();
166                res = pm.getResourcesForApplication(packageName);
167            }
168
169            int id = 0;
170            List<String> segments = uri.getPathSegments();
171            int size = segments.size();
172            if (size == 2 && segments.get(0).equals("drawable")) {
173                String resName = segments.get(1);
174                id = res.getIdentifier(resName, "drawable", packageName);
175            } else if (size == 1 && TextUtils.isDigitsOnly(segments.get(0))) {
176                try {
177                    id = Integer.parseInt(segments.get(0));
178                } catch (NumberFormatException ignored) {
179                }
180            }
181            try {
182                AssetFileDescriptor descriptor = context.getResources().openRawResourceFd(id);
183                fileLength = descriptor.getLength();
184            } catch (Exception e) {
185                // Pooling disabled
186            }
187            decoder = BitmapRegionDecoder.newInstance(context.getResources().openRawResource(id), false);
188        } else if (uriString.startsWith(ASSET_PREFIX)) {
189            String assetName = uriString.substring(ASSET_PREFIX.length());
190            try {
191                AssetFileDescriptor descriptor = context.getAssets().openFd(assetName);
192                fileLength = descriptor.getLength();
193            } catch (Exception e) {
194                // Pooling disabled
195            }
196            decoder = BitmapRegionDecoder.newInstance(context.getAssets().open(assetName, AssetManager.ACCESS_RANDOM), false);
197        } else if (uriString.startsWith(FILE_PREFIX)) {
198            decoder = BitmapRegionDecoder.newInstance(uriString.substring(FILE_PREFIX.length()), false);
199            try {
200                File file = new File(uriString);
201                if (file.exists()) {
202                    fileLength = file.length();
203                }
204            } catch (Exception e) {
205                // Pooling disabled
206            }
207        } else {
208            InputStream inputStream = null;
209            try {
210                ContentResolver contentResolver = context.getContentResolver();
211                inputStream = contentResolver.openInputStream(uri);
212                decoder = BitmapRegionDecoder.newInstance(inputStream, false);
213                try {
214                    AssetFileDescriptor descriptor = contentResolver.openAssetFileDescriptor(uri, "r");
215                    if (descriptor != null) {
216                        fileLength = descriptor.getLength();
217                    }
218                } catch (Exception e) {
219                    // Stick with MAX_LENGTH
220                }
221            } finally {
222                if (inputStream != null) {
223                    try { inputStream.close(); } catch (Exception e) { /* Ignore */ }
224                }
225            }
226        }
227
228        this.fileLength = fileLength;
229        this.imageDimensions.set(decoder.getWidth(), decoder.getHeight());
230        decoderLock.writeLock().lock();
231        try {
232            if (decoderPool != null) {
233                decoderPool.add(decoder);
234            }
235        } finally {
236            decoderLock.writeLock().unlock();
237        }
238    }
239
240    /**
241     * Acquire a read lock to prevent decoding overlapping with recycling, then check the pool still
242     * exists and acquire a decoder to load the requested region. There is no check whether the pool
243     * currently has decoders, because it's guaranteed to have one decoder after {@link #init(Context, Uri)}
244     * is called and be null once {@link #recycle()} is called. In practice the view can't call this
245     * method until after {@link #init(Context, Uri)}, so there will be no blocking on an empty pool.
246     */
247    @Override
248    public Bitmap decodeRegion(Rect sRect, int sampleSize) {
249        debug("Decode region " + sRect + " on thread " + Thread.currentThread().getName());
250        if (sRect.width() < imageDimensions.x || sRect.height() < imageDimensions.y) {
251            lazyInit();
252        }
253        decoderLock.readLock().lock();
254        try {
255            if (decoderPool != null) {
256                BitmapRegionDecoder decoder = decoderPool.acquire();
257                try {
258                    // Decoder can't be null or recycled in practice
259                    if (decoder != null && !decoder.isRecycled()) {
260                        BitmapFactory.Options options = new BitmapFactory.Options();
261                        options.inSampleSize = sampleSize;
262                        options.inPreferredConfig = bitmapConfig;
263                        Bitmap bitmap = decoder.decodeRegion(sRect, options);
264                        if (bitmap == null) {
265                            throw new RuntimeException("Skia image decoder returned null bitmap - image format may not be supported");
266                        }
267                        return bitmap;
268                    }
269                } finally {
270                    if (decoder != null) {
271                        decoderPool.release(decoder);
272                    }
273                }
274            }
275            throw new IllegalStateException("Cannot decode region after decoder has been recycled");
276        } finally {
277            decoderLock.readLock().unlock();
278        }
279    }
280
281    /**
282     * Holding a read lock to avoid returning true while the pool is being recycled, this returns
283     * true if the pool has at least one decoder available.
284     */
285    @Override
286    public synchronized boolean isReady() {
287        return decoderPool != null && !decoderPool.isEmpty();
288    }
289
290    /**
291     * Wait until all read locks held by {@link #decodeRegion(Rect, int)} are released, then recycle
292     * and destroy the pool. Elsewhere, when a read lock is acquired, we must check the pool is not null.
293     */
294    @Override
295    public synchronized void recycle() {
296        decoderLock.writeLock().lock();
297        try {
298            if (decoderPool != null) {
299                decoderPool.recycle();
300                decoderPool = null;
301                context = null;
302                uri = null;
303            }
304        } finally {
305            decoderLock.writeLock().unlock();
306        }
307    }
308
309    /**
310     * Called before creating a new decoder. Based on number of CPU cores, available memory, and the
311     * size of the image file, determines whether another decoder can be created. Subclasses can
312     * override and customise this.
313     * @param numberOfDecoders the number of decoders that have been created so far
314     * @param fileLength the size of the image file in bytes. Creating another decoder will use approximately this much native memory.
315     * @return true if another decoder can be created.
316     */
317    @SuppressWarnings("WeakerAccess")
318    protected boolean allowAdditionalDecoder(int numberOfDecoders, long fileLength) {
319        if (numberOfDecoders >= 4) {
320            debug("No additional decoders allowed, reached hard limit (4)");
321            return false;
322        } else if (numberOfDecoders * fileLength > 20 * 1024 * 1024) {
323            debug("No additional encoders allowed, reached hard memory limit (20Mb)");
324            return false;
325        } else if (numberOfDecoders >= getNumberOfCores()) {
326            debug("No additional encoders allowed, limited by CPU cores (" + getNumberOfCores() + ")");
327            return false;
328        } else if (isLowMemory()) {
329            debug("No additional encoders allowed, memory is low");
330            return false;
331        }
332        debug("Additional decoder allowed, current count is " + numberOfDecoders + ", estimated native memory " + ((fileLength * numberOfDecoders)/(1024 * 1024)) + "Mb");
333        return true;
334    }
335
336
337    /**
338     * A simple pool of {@link BitmapRegionDecoder} instances, all loading from the same source.
339     */
340    private static class DecoderPool {
341        private final Semaphore available = new Semaphore(0, true);
342        private final Map<BitmapRegionDecoder, Boolean> decoders = new ConcurrentHashMap<>();
343
344        /**
345         * Returns false if there is at least one decoder in the pool.
346         */
347        private synchronized boolean isEmpty() {
348            return decoders.isEmpty();
349        }
350
351        /**
352         * Returns number of encoders.
353         */
354        private synchronized int size() {
355            return decoders.size();
356        }
357
358        /**
359         * Acquire a decoder. Blocks until one is available.
360         */
361        private BitmapRegionDecoder acquire() {
362            available.acquireUninterruptibly();
363            return getNextAvailable();
364        }
365
366        /**
367         * Release a decoder back to the pool.
368         */
369        private void release(BitmapRegionDecoder decoder) {
370            if (markAsUnused(decoder)) {
371                available.release();
372            }
373        }
374
375        /**
376         * Adds a newly created decoder to the pool, releasing an additional permit.
377         */
378        private synchronized void add(BitmapRegionDecoder decoder) {
379            decoders.put(decoder, false);
380            available.release();
381        }
382
383        /**
384         * While there are decoders in the map, wait until each is available before acquiring,
385         * recycling and removing it. After this is called, any call to {@link #acquire()} will
386         * block forever, so this call should happen within a write lock, and all calls to
387         * {@link #acquire()} should be made within a read lock so they cannot end up blocking on
388         * the semaphore when it has no permits.
389         */
390        private synchronized void recycle() {
391            while (!decoders.isEmpty()) {
392                BitmapRegionDecoder decoder = acquire();
393                decoder.recycle();
394                decoders.remove(decoder);
395            }
396        }
397
398        private synchronized BitmapRegionDecoder getNextAvailable() {
399            for (Map.Entry<BitmapRegionDecoder, Boolean> entry : decoders.entrySet()) {
400                if (!entry.getValue()) {
401                    entry.setValue(true);
402                    return entry.getKey();
403                }
404            }
405            return null;
406        }
407
408        private synchronized boolean markAsUnused(BitmapRegionDecoder decoder) {
409            for (Map.Entry<BitmapRegionDecoder, Boolean> entry : decoders.entrySet()) {
410                if (decoder == entry.getKey()) {
411                    if (entry.getValue()) {
412                        entry.setValue(false);
413                        return true;
414                    } else {
415                        return false;
416                    }
417                }
418            }
419            return false;
420        }
421
422    }
423
424    private int getNumberOfCores() {
425        if (Build.VERSION.SDK_INT >= 17) {
426            return Runtime.getRuntime().availableProcessors();
427        } else {
428            return getNumCoresOldPhones();
429        }
430    }
431
432    /**
433     * Gets the number of cores available in this device, across all processors.
434     * Requires: Ability to peruse the filesystem at "/sys/devices/system/cpu"
435     * @return The number of cores, or 1 if failed to get result
436     */
437    private int getNumCoresOldPhones() {
438        class CpuFilter implements FileFilter {
439            @Override
440            public boolean accept(File pathname) {
441                return Pattern.matches("cpu[0-9]+", pathname.getName());
442            }
443        }
444        try {
445            File dir = new File("/sys/devices/system/cpu/");
446            File[] files = dir.listFiles(new CpuFilter());
447            return files.length;
448        } catch(Exception e) {
449            return 1;
450        }
451    }
452
453    private boolean isLowMemory() {
454        ActivityManager activityManager = (ActivityManager)context.getSystemService(ACTIVITY_SERVICE);
455        if (activityManager != null) {
456            ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo();
457            activityManager.getMemoryInfo(memoryInfo);
458            return memoryInfo.lowMemory;
459        } else {
460            return true;
461        }
462    }
463
464    private void debug(String message) {
465        if (debug) {
466            Log.d(TAG, message);
467        }
468    }
469
470}
471