<NME> ImagesList.java
<BEF> package cgeo.geocaching.ui;

import cgeo.geocaching.R;
import cgeo.geocaching.apps.navi.NavigationAppFactory;
import cgeo.geocaching.enumerations.WaypointType;
import cgeo.geocaching.location.Geopoint;
import cgeo.geocaching.models.Geocache;
import cgeo.geocaching.models.Image;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;

import android.app.Activity;
import android.content.Intent;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.text.Html;
import android.util.SparseArray;
import android.view.ContextMenu;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.content.FileProvider;
import androidx.core.text.HtmlCompat;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.util.Collection;
import java.util.LinkedList;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;

public class ImagesList {

    private BitmapDrawable currentDrawable;
    private Image currentImage;

    public enum ImageType {
        LogImages(R.string.cache_log_images_title),

    public enum ImageType {
        LogImages(R.string.cache_log_images_title), SpoilerImages(R.string.cache_spoiler_images_title);

        @StringRes private final int titleResId;

        ImageType(@StringRes final int title) {
            this.titleResId = title;
        }

        @StringRes
        public int getTitle() {
            return titleResId;
        }
    }

    private LayoutInflater inflater = null;
    private final Activity activity;
    // We could use a Set here, but we will insert no duplicates, so there is no need to check for uniqueness.
    private final Collection<Bitmap> bitmaps = new LinkedList<>();
    /**
     * map image view id to image
    private final String geocode;
    private LinearLayout imagesView;

    public ImagesList(final Activity activity, final String geocode) {
        this.activity = activity;
        this.geocode = geocode;
        this.geocode = geocode;
        this.geocache = geocache;
        inflater = activity.getLayoutInflater();
    }

        imagesView = (LinearLayout) parentView.findViewById(R.id.spoiler_list);

        for (final Image img : images) {
            LinearLayout rowView = (LinearLayout) inflater.inflate(R.layout.cache_image_item, null);

            if (StringUtils.isNotBlank(img.getTitle())) {
                ((TextView) rowView.findViewById(R.id.title)).setText(Html.fromHtml(img.getTitle()));
        // Start with a fresh disposable because of this method can be called several times if the
        // enclosing activity is stopped/restarted.
        final CompositeDisposable disposables = new CompositeDisposable(new CancellableDisposable(this::removeAllViews));

        imagesView = parentView.findViewById(R.id.spoiler_list);
        imagesView.removeAllViews();
                descView.setVisibility(View.VISIBLE);
            }

            new AsyncImgLoader(rowView, img, offline).execute();
            imagesView.addView(rowView);
        }
    }

    private class AsyncImgLoader extends AsyncTask<Void, Void, BitmapDrawable> {

        final private LinearLayout view;
        final private Image img;
        final boolean offline;

        public AsyncImgLoader(final LinearLayout view, final Image img, final boolean offline) {
            this.view = view;
            this.img = img;
            this.offline = offline;
        }

        @Override
        protected BitmapDrawable doInBackground(Void... params) {
            final HtmlImage imgGetter = new HtmlImage(geocode, true, offline ? StoredList.STANDARD_LIST_ID : StoredList.TEMPORARY_LIST_ID, false);
            return imgGetter.getDrawable(img.getUrl());
        }

        @Override
        protected void onPostExecute(final BitmapDrawable image) {
            if (image != null) {
                bitmaps.add(image.getBitmap());
                final ImageView imageView = (ImageView) inflater.inflate(R.layout.image_item, null);

                final Rect bounds = image.getBounds();

                imageView.setImageResource(R.drawable.image_not_loaded);
                imageView.setClickable(true);
                imageView.setOnClickListener(new View.OnClickListener() {

                    @Override
                    public void onClick(View arg0) {
                        viewImageInStandardApp(image);
                    }
                });
                activity.registerForContextMenu(imageView);
                imageView.setImageDrawable(image);
                imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
                imageView.setLayoutParams(new LayoutParams(bounds.width(), bounds.height()));

                view.findViewById(R.id.progress_bar).setVisibility(View.GONE);
                view.addView(imageView);

                imageView.setId(image.hashCode());
                images.put(imageView.getId(), img);
            }
        }
    }

    public void removeAllViews() {
        imagesView.removeAllViews();
        for (final Bitmap b : bitmaps) {
            b.recycle();
        }
        bitmaps.clear();
    }

    public void onCreateContextMenu(ContextMenu menu, View v) {
            for (final GpsDirectory gpsDirectory : gpsDirectories) {
                // Try to read out the location, making sure it's non-zero
                final GeoLocation geoLocation = gpsDirectory.getGeoLocation();
                if (geoLocation != null && !geoLocation.isZero()) {
                    return new Geopoint(geoLocation.getLatitude(), geoLocation.getLongitude());
                }
            }
        } catch (final Exception e) {
            Log.i("ImagesList.getImageLocation", e);
        }
        return null;
    }

    private void addGeoOverlay(final RelativeLayout imageViewLayout, final Geopoint gpt) {
        final ImageView geoOverlay = (ImageView) imageViewLayout.findViewById(R.id.geo_overlay);
        geoOverlay.setVisibility(View.VISIBLE);
        geoOverlay.setOnClickListener(wpNavView -> {
            wpNavView.setOnClickListener(v -> NavigationAppFactory.startDefaultNavigationApplication(1, activity, gpt));
            wpNavView.setOnLongClickListener(v -> {
                NavigationAppFactory.startDefaultNavigationApplication(2, activity, gpt);
                return true;
            });
        });
    }

    private void removeAllViews() {
        for (final Bitmap b : bitmaps) {
            b.recycle();
        }
        bitmaps.clear();
        images.clear();
        geoPoints.clear();

        imagesView.removeAllViews();
    }

    public void onCreateContextMenu(final ContextMenu menu, final View v) {
        activity.getMenuInflater().inflate(R.menu.images_list_context, menu);
        final Resources res = activity.getResources();
        menu.setHeaderTitle(res.getString(R.string.cache_image));
        currentView = (ImageView) v;
        currentImage = images.get(currentView.getId());
        final boolean hasCoordinates = geoPoints.get(currentView.getId()) != null;
        menu.findItem(R.id.image_add_waypoint).setVisible(hasCoordinates && geocache != null);
        menu.findItem(R.id.menu_navigate).setVisible(hasCoordinates);
    }

    public boolean onContextItemSelected(final MenuItem item) {
        final BitmapDrawable currentDrawable = (BitmapDrawable) currentView.getDrawable();
        final int itemId = item.getItemId();
        if (itemId == R.id.image_open_file) {
            viewImageInStandardApp(currentImage, currentDrawable);
        } else if (itemId == R.id.image_open_browser) {
            if (currentImage != null) {
                currentImage.openInBrowser(activity);
            }
        } else if (itemId == R.id.image_add_waypoint) {
            final Geopoint coords = geoPoints.get(currentView.getId());
            if (geocache != null && coords != null) {
                final Waypoint waypoint = new Waypoint(currentImage.getTitle(), WaypointType.WAYPOINT, true);
                waypoint.setCoords(coords);
                geocache.addOrChangeWaypoint(waypoint, true);
                GeocacheChangedBroadcastReceiver.sendBroadcast(activity, geocode);
            }
        } else if (itemId == R.id.menu_navigate) {
            final Geopoint geopoint = geoPoints.get(currentView.getId());
            if (geopoint != null) {
                NavigationAppFactory.showNavigationMenu(activity, null, null, geopoint);
            }
        } else {
            return false;
        }
        return true;
    }

    private static String mimeTypeForUrl(final String url) {
        return StringUtils.defaultString(MimeTypeMap.getSingleton().getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(url)), "image/*");
    }

    private static File saveToTemporaryJPGFile(final BitmapDrawable image) throws FileNotFoundException {
        final File file = LocalStorage.getGeocacheDataFile(HtmlImage.SHARED, "temp.jpg", false, true);
        BufferedOutputStream stream = null;
        try {
            stream = new BufferedOutputStream(new FileOutputStream(file));
            image.getBitmap().compress(CompressFormat.JPEG, 100, stream);
        } finally {
            IOUtils.closeQuietly(stream);
        }
        file.deleteOnExit();
        return file;
    }

    private void viewImageInStandardApp(final Image img, final BitmapDrawable image) {
        try {
            final Intent intent = new Intent().setAction(Intent.ACTION_VIEW);
            final File file = img.isLocalFile() ? img.localFile() : LocalStorage.getGeocacheDataFile(geocode, img.getUrl(), true, true);
            final String authority = activity.getApplicationContext().getString(R.string.file_provider_authority);
            if (file.exists()) {
                intent.setDataAndType(
                        FileProvider.getUriForFile(activity, authority, file),
                        mimeTypeForUrl(img.getUrl())
                );
            } else {
                intent.setDataAndType(
                        FileProvider.getUriForFile(activity, authority, saveToTemporaryJPGFile(image)),
                        "image/jpeg"
                );
            }
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
            activity.startActivity(intent);
        } catch (final Exception e) {
            Log.e("ImagesList.viewImageInStandardApp", e);
        }
    }

}

<MSG> fix #3536: exhaustion of simultaneous AsyncTask thread pool

Image loading in gallery is now limited to 5 images at a time. Also, if
resources are freed, queued images load requests are also discarded.

<DFF> @@ -9,6 +9,16 @@ import cgeo.geocaching.utils.Log;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.StringUtils;
+import rx.Observable;
+import rx.Observable.OnSubscribeFunc;
+import rx.Observer;
+import rx.Scheduler;
+import rx.Subscription;
+import rx.android.concurrency.AndroidSchedulers;
+import rx.concurrency.Schedulers;
+import rx.subscriptions.CompositeSubscription;
+import rx.subscriptions.Subscriptions;
+import rx.util.functions.Action1;
 
 import android.app.Activity;
 import android.content.Intent;
@@ -18,7 +28,6 @@ import android.graphics.Bitmap.CompressFormat;
 import android.graphics.Rect;
 import android.graphics.drawable.BitmapDrawable;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.text.Html;
 import android.util.SparseArray;
 import android.view.ContextMenu;
@@ -36,11 +45,15 @@ import java.io.FileOutputStream;
 import java.util.Collection;
 import java.util.LinkedList;
 import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 
 public class ImagesList {
 
     private BitmapDrawable currentDrawable;
     private Image currentImage;
+    private CompositeSubscription subscriptions = new CompositeSubscription();
 
     public enum ImageType {
         LogImages(R.string.cache_log_images_title),
@@ -69,6 +82,9 @@ public class ImagesList {
     private final String geocode;
     private LinearLayout imagesView;
 
+    private Scheduler downloadScheduler = Schedulers.executor(new ThreadPoolExecutor(5, 5, 5, TimeUnit.SECONDS,
+            new LinkedBlockingQueue<Runnable>()));
+
     public ImagesList(final Activity activity, final String geocode) {
         this.activity = activity;
         this.geocode = geocode;
@@ -80,7 +96,8 @@ public class ImagesList {
         imagesView = (LinearLayout) parentView.findViewById(R.id.spoiler_list);
 
         for (final Image img : images) {
-            LinearLayout rowView = (LinearLayout) inflater.inflate(R.layout.cache_image_item, null);
+            final LinearLayout rowView = (LinearLayout) inflater.inflate(R.layout.cache_image_item, null);
+            assert(rowView != null);
 
             if (StringUtils.isNotBlank(img.getTitle())) {
                 ((TextView) rowView.findViewById(R.id.title)).setText(Html.fromHtml(img.getTitle()));
@@ -93,66 +110,69 @@ public class ImagesList {
                 descView.setVisibility(View.VISIBLE);
             }
 
-            new AsyncImgLoader(rowView, img, offline).execute();
+            subscriptions.add(loadImage(img, offline)
+                    .subscribeOn(downloadScheduler)
+                    .observeOn(AndroidSchedulers.mainThread()).subscribe(new Action1<BitmapDrawable>() {
+                        @Override
+                        public void call(final BitmapDrawable image) {
+                            display(image, img, rowView);
+                        }
+                    }));
+
             imagesView.addView(rowView);
         }
     }
 
-    private class AsyncImgLoader extends AsyncTask<Void, Void, BitmapDrawable> {
+    private Observable<BitmapDrawable> loadImage(final Image img, final boolean offline) {
+        return Observable.create(new OnSubscribeFunc<BitmapDrawable>() {
+            @Override
+            public Subscription onSubscribe(final Observer<? super BitmapDrawable> observer) {
+                final HtmlImage imgGetter = new HtmlImage(geocode, true, offline ? StoredList.STANDARD_LIST_ID : StoredList.TEMPORARY_LIST_ID, false);
+                observer.onNext(imgGetter.getDrawable(img.getUrl()));
+                observer.onCompleted();
+                return Subscriptions.empty();
+            }
+        });
+    }
 
-        final private LinearLayout view;
-        final private Image img;
-        final boolean offline;
+    private void display(final BitmapDrawable image, final Image img, final LinearLayout view) {
+        if (image != null) {
+            bitmaps.add(image.getBitmap());
+            final ImageView imageView = (ImageView) inflater.inflate(R.layout.image_item, null);
+            assert(imageView != null);
 
-        public AsyncImgLoader(final LinearLayout view, final Image img, final boolean offline) {
-            this.view = view;
-            this.img = img;
-            this.offline = offline;
-        }
+            final Rect bounds = image.getBounds();
 
-        @Override
-        protected BitmapDrawable doInBackground(Void... params) {
-            final HtmlImage imgGetter = new HtmlImage(geocode, true, offline ? StoredList.STANDARD_LIST_ID : StoredList.TEMPORARY_LIST_ID, false);
-            return imgGetter.getDrawable(img.getUrl());
-        }
+            imageView.setImageResource(R.drawable.image_not_loaded);
+            imageView.setClickable(true);
+            imageView.setOnClickListener(new View.OnClickListener() {
+                @Override
+                public void onClick(View arg0) {
+                    viewImageInStandardApp(image);
+                }
+            });
+            activity.registerForContextMenu(imageView);
+            imageView.setImageDrawable(image);
+            imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
+            imageView.setLayoutParams(new LayoutParams(bounds.width(), bounds.height()));
 
-        @Override
-        protected void onPostExecute(final BitmapDrawable image) {
-            if (image != null) {
-                bitmaps.add(image.getBitmap());
-                final ImageView imageView = (ImageView) inflater.inflate(R.layout.image_item, null);
-
-                final Rect bounds = image.getBounds();
-
-                imageView.setImageResource(R.drawable.image_not_loaded);
-                imageView.setClickable(true);
-                imageView.setOnClickListener(new View.OnClickListener() {
-
-                    @Override
-                    public void onClick(View arg0) {
-                        viewImageInStandardApp(image);
-                    }
-                });
-                activity.registerForContextMenu(imageView);
-                imageView.setImageDrawable(image);
-                imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
-                imageView.setLayoutParams(new LayoutParams(bounds.width(), bounds.height()));
-
-                view.findViewById(R.id.progress_bar).setVisibility(View.GONE);
-                view.addView(imageView);
-
-                imageView.setId(image.hashCode());
-                images.put(imageView.getId(), img);
-            }
+            view.findViewById(R.id.progress_bar).setVisibility(View.GONE);
+            view.addView(imageView);
+
+            imageView.setId(image.hashCode());
+            images.put(imageView.getId(), img);
         }
     }
 
     public void removeAllViews() {
-        imagesView.removeAllViews();
         for (final Bitmap b : bitmaps) {
             b.recycle();
         }
         bitmaps.clear();
+
+        // Stop loading images if some are still in progress
+        subscriptions.unsubscribe();
+        imagesView.removeAllViews();
     }
 
     public void onCreateContextMenu(ContextMenu menu, View v) {

fix #3536: exhaustion of simultaneous AsyncTask thread pool