@Test(groups = "standalone")
  public void testObserveMultiple() {
    final TestSubscriber<Response> tester = new TestSubscriber<>();

    try (AsyncHttpClient client = asyncHttpClient()) {
      Observable<Response> o1 =
          AsyncHttpObservable.observe(() -> client.prepareGet("http://gatling.io"));
      Observable<Response> o2 =
          AsyncHttpObservable.observe(
              () -> client.prepareGet("http://www.wisc.edu").setFollowRedirect(true));
      Observable<Response> o3 =
          AsyncHttpObservable.observe(
              () -> client.prepareGet("http://www.umn.edu").setFollowRedirect(true));
      Observable<Response> all = Observable.merge(o1, o2, o3);
      all.subscribe(tester);
      tester.awaitTerminalEvent();
      tester.assertTerminalEvent();
      tester.assertCompleted();
      tester.assertNoErrors();
      List<Response> responses = tester.getOnNextEvents();
      assertNotNull(responses);
      assertEquals(responses.size(), 3);
      for (Response response : responses) {
        assertEquals(response.getStatusCode(), 200);
      }
    } catch (Exception e) {
      Thread.currentThread().interrupt();
    }
  }
  /** IO scheduler defaults to using CachedThreadScheduler */
  @Test
  public final void testIOScheduler() {

    Observable<Integer> o1 = Observable.from(1, 2, 3, 4, 5);
    Observable<Integer> o2 = Observable.from(6, 7, 8, 9, 10);
    Observable<String> o =
        Observable.merge(o1, o2)
            .map(
                new Func1<Integer, String>() {

                  @Override
                  public String call(Integer t) {
                    assertTrue(
                        Thread.currentThread().getName().startsWith("RxCachedThreadScheduler"));
                    return "Value_" + t + "_Thread_" + Thread.currentThread().getName();
                  }
                });

    o.subscribeOn(Schedulers.io())
        .toBlocking()
        .forEach(
            new Action1<String>() {

              @Override
              public void call(String t) {
                System.out.println("t: " + t);
              }
            });
  }
  public Observable<Pair<IAutomationMethod, Float>> getKeyEvents() {
    ArrayList<Observable<Pair<IAutomationMethod, Float>>> keyEvents =
        new ArrayList<Observable<Pair<IAutomationMethod, Float>>>();

    for (Map.Entry<Component.Identifier, IAutomationMethod> entry :
        automationMethodHashMap.entrySet()) {

      IRxAutomationProjection projector = identifierToProjectionMap.get(entry.getKey());
      Observable<Pair<IAutomationMethod, Float>> keyEvent =
          projector.map(entry.getValue(), this.rxGamePad.getComponentById(entry.getKey()));
      keyEvents.add(keyEvent);
    }

    return Observable.merge(keyEvents);
  }
  private void bind(Photos photos) {
    mSwipeRefreshLayout.setRefreshing(false);
    mPhotos = photos;
    mPhotosAdapter = new PhotosAdapter(mPhotos.getPhotos());
    mRecyclerView.setAdapter(mPhotosAdapter);

    Observable.merge(
            mPhotosAdapter
                .getThumbnailClickedSubject()
                .filter(PhotosValidator::isSourceValid)
                .map(Photo::getSourceUrl),
            mPhotosAdapter
                .getItemClickedSubject()
                .filter(photo -> StringUtils.isValidUrl(photo.getPermalink()))
                .map(Photo::getPermalink))
        .compose(bindUntilEvent(ActivityEvent.DESTROY))
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(OnlyNextObserver.forAction(this::view));
  }
 private void subscribeToChanges() {
   RxSharedPreferences prefs =
       RxSharedPreferences.create(PreferenceManager.getDefaultSharedPreferences(mAppContext));
   Preference<Integer> lastPage = prefs.getInteger(Constants.PREF_LAST_PAGE);
   Observable.merge(
           mBookmarkModel.tagsObservable(),
           mBookmarkModel.bookmarksObservable(),
           lastPage.asObservable())
       .observeOn(AndroidSchedulers.mainThread())
       .subscribe(
           new Action1<Object>() {
             @Override
             public void call(Object o) {
               if (mFragment != null) {
                 requestData(false);
               } else {
                 mCachedData = null;
               }
             }
           });
 }
  @Test
  public void testNumberOfThreadsOnScheduledMerge() {
    final ConcurrentHashMap<Long, Long> threads = new ConcurrentHashMap<Long, Long>();

    // now we parallelMerge into 3 streams and observeOn for each
    // we expect 3 threads in the output
    Observable.merge(Observable.parallelMerge(getStreams(), 3, Schedulers.newThread()))
        .toBlocking()
        .forEach(
            new Action1<String>() {

              @Override
              public void call(String o) {
                System.out.println("o: " + o + " Thread: " + Thread.currentThread().getId());
                threads.put(Thread.currentThread().getId(), Thread.currentThread().getId());
              }
            });

    assertTrue(
        threads.keySet().size()
            <= 3); // can be less than since merge doesn't block threads and may not use all of them
  }
/**
 * All-purpose image getter that can also be used as a ImageGetter interface when displaying caches.
 */
public class HtmlImage implements Html.ImageGetter {

  private static final String[] BLOCKED = {
    "gccounter.de",
    "gccounter.com",
    "cachercounter/?",
    "gccounter/imgcount.php",
    "flagcounter.com",
    "compteur-blog.net",
    "counter.digits.com",
    "andyhoppe",
    "besucherzaehler-homepage.de",
    "hitwebcounter.com",
    "kostenloser-counter.eu",
    "trendcounter.com",
    "hit-counter-download.com",
    "gcwetterau.de/counter"
  };
  public static final String SHARED = "shared";

  @NonNull private final String geocode;
  /** on error: return large error image, if {@code true}, otherwise empty 1x1 image */
  private final boolean returnErrorImage;

  private final int listId;
  private final boolean onlySave;
  private final int maxWidth;
  private final int maxHeight;
  private final Resources resources;
  protected final TextView view;
  private final Map<String, BitmapDrawable> cache = new HashMap<>();

  private final ObservableCache<String, BitmapDrawable> observableCache =
      new ObservableCache<>(
          new Func1<String, Observable<BitmapDrawable>>() {
            @Override
            public Observable<BitmapDrawable> call(final String url) {
              return fetchDrawableUncached(url);
            }
          });

  // Background loading
  private final PublishSubject<Observable<String>> loading = PublishSubject.create();
  private final Observable<String> waitForEnd = Observable.merge(loading).cache();
  private final CompositeSubscription subscription =
      new CompositeSubscription(waitForEnd.subscribe());

  /**
   * Create a new HtmlImage object with different behaviors depending on <tt>onlySave</tt> and
   * <tt>view</tt> values. There are the three possible use cases:
   *
   * <ul>
   *   <li>If onlySave is true, {@link #getDrawable(String)} will return <tt>null</tt> immediately
   *       and will queue the image retrieval and saving in the loading subject. Downloads will
   *       start in parallel when the blocking {@link
   *       #waitForEndObservable(cgeo.geocaching.utils.CancellableHandler)} method is called, and
   *       they can be cancelled through the given handler.
   *   <li>If <tt>onlySave</tt> is <tt>false</tt> and the instance is called through {@link
   *       #fetchDrawable(String)}, then an observable for the given URL will be returned. This
   *       observable will emit the local copy of the image if it is present regardless of its
   *       freshness, then if needed an updated fresher copy after retrieving it from the network.
   *   <li>If <tt>onlySave</tt> is <tt>false</tt> and the instance is used as an {@link
   *       android.text.Html.ImageGetter}, only the final version of the image will be returned,
   *       unless a view has been provided. If it has, then a dummy drawable is returned and is
   *       updated when the image is available, possibly several times if we had a stale copy of the
   *       image and then got a new one from the network.
   * </ul>
   *
   * @param geocode the geocode of the item for which we are requesting the image, or {@link
   *     #SHARED} to use the shared cache directory
   * @param returnErrorImage set to <tt>true</tt> if an error image should be returned in case of a
   *     problem, <tt>false</tt> to get a transparent 1x1 image instead
   * @param listId the list this cache belongs to, used to determine if an older image for the
   *     offline case can be used or not
   * @param onlySave if set to <tt>true</tt>, {@link #getDrawable(String)} will only fetch and store
   *     the image, not return it
   * @param view if non-null, {@link #getDrawable(String)} will return an initially empty drawable
   *     which will be redrawn when the image is ready through an invalidation of the given view
   */
  public HtmlImage(
      @NonNull final String geocode,
      final boolean returnErrorImage,
      final int listId,
      final boolean onlySave,
      final TextView view) {
    this.geocode = geocode;
    this.returnErrorImage = returnErrorImage;
    this.listId = listId;
    this.onlySave = onlySave;
    this.view = view;

    final Point displaySize = Compatibility.getDisplaySize();
    this.maxWidth = displaySize.x - 25;
    this.maxHeight = displaySize.y - 25;
    this.resources = CgeoApplication.getInstance().getResources();
  }

  /**
   * Create a new HtmlImage object with different behaviors depending on <tt>onlySave</tt> value. No
   * view object will be tied to this HtmlImage.
   *
   * <p>For documentation, see {@link #HtmlImage(String, boolean, int, boolean, TextView)}.
   */
  public HtmlImage(
      @NonNull final String geocode,
      final boolean returnErrorImage,
      final int listId,
      final boolean onlySave) {
    this(geocode, returnErrorImage, listId, onlySave, null);
  }

  /**
   * Retrieve and optionally display an image. See {@link #HtmlImage(String, boolean, int, boolean,
   * TextView)} for the various behaviours.
   *
   * @param url the URL to fetch from cache or network
   * @return a drawable containing the image, or <tt>null</tt> if <tt>onlySave</tt> is <tt>true</tt>
   */
  @Nullable
  @Override
  public BitmapDrawable getDrawable(final String url) {
    if (cache.containsKey(url)) {
      return cache.get(url);
    }
    final Observable<BitmapDrawable> drawable = fetchDrawable(url);
    if (onlySave) {
      loading.onNext(
          drawable.map(
              new Func1<BitmapDrawable, String>() {
                @Override
                public String call(final BitmapDrawable bitmapDrawable) {
                  return url;
                }
              }));
      cache.put(url, null);
      return null;
    }
    final BitmapDrawable result =
        view == null ? drawable.toBlocking().lastOrDefault(null) : getContainerDrawable(drawable);
    cache.put(url, result);
    return result;
  }

  protected BitmapDrawable getContainerDrawable(final Observable<BitmapDrawable> drawable) {
    return new ContainerDrawable(view, drawable);
  }

  public Observable<BitmapDrawable> fetchDrawable(final String url) {
    return observableCache.get(url);
  }

  // Caches are loaded from disk on a computation scheduler to avoid using more threads than cores
  // while decoding
  // the image. Downloads happen on downloadScheduler, in parallel with image decoding.
  private Observable<BitmapDrawable> fetchDrawableUncached(final String url) {
    if (StringUtils.isBlank(url) || ImageUtils.containsPattern(url, BLOCKED)) {
      return Observable.just(ImageUtils.getTransparent1x1Drawable(resources));
    }

    // Explicit local file URLs are loaded from the filesystem regardless of their age. The IO part
    // is short
    // enough to make the whole operation on the computation scheduler.
    if (FileUtils.isFileUrl(url)) {
      return Observable.defer(
              new Func0<Observable<BitmapDrawable>>() {
                @Override
                public Observable<BitmapDrawable> call() {
                  final Bitmap bitmap = loadCachedImage(FileUtils.urlToFile(url), true).left;
                  return bitmap != null
                      ? Observable.just(ImageUtils.scaleBitmapToFitDisplay(bitmap))
                      : Observable.<BitmapDrawable>empty();
                }
              })
          .subscribeOn(AndroidRxUtils.computationScheduler);
    }

    final boolean shared = url.contains("/images/icons/icon_");
    final String pseudoGeocode = shared ? SHARED : geocode;

    return Observable.create(
        new OnSubscribe<BitmapDrawable>() {
          @Override
          public void call(final Subscriber<? super BitmapDrawable> subscriber) {
            subscription.add(subscriber);
            subscriber.add(
                AndroidRxUtils.computationScheduler
                    .createWorker()
                    .schedule(
                        new Action0() {
                          @Override
                          public void call() {
                            final ImmutablePair<BitmapDrawable, Boolean> loaded = loadFromDisk();
                            final BitmapDrawable bitmap = loaded.left;
                            if (loaded.right) {
                              subscriber.onNext(bitmap);
                              subscriber.onCompleted();
                              return;
                            }
                            if (bitmap != null && !onlySave) {
                              subscriber.onNext(bitmap);
                            }
                            AndroidRxUtils.networkScheduler
                                .createWorker()
                                .schedule(
                                    new Action0() {
                                      @Override
                                      public void call() {
                                        downloadAndSave(subscriber);
                                      }
                                    });
                          }
                        }));
          }

          private ImmutablePair<BitmapDrawable, Boolean> loadFromDisk() {
            final ImmutablePair<Bitmap, Boolean> loadResult =
                loadImageFromStorage(url, pseudoGeocode, shared);
            return scaleImage(loadResult);
          }

          private void downloadAndSave(final Subscriber<? super BitmapDrawable> subscriber) {
            final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, true);
            if (url.startsWith("data:image/")) {
              if (url.contains(";base64,")) {
                ImageUtils.decodeBase64ToFile(StringUtils.substringAfter(url, ";base64,"), file);
              } else {
                Log.e("HtmlImage.getDrawable: unable to decode non-base64 inline image");
                subscriber.onCompleted();
                return;
              }
            } else if (subscriber.isUnsubscribed() || downloadOrRefreshCopy(url, file)) {
              // The existing copy was fresh enough or we were unsubscribed earlier.
              subscriber.onCompleted();
              return;
            }
            if (onlySave) {
              subscriber.onCompleted();
              return;
            }
            AndroidRxUtils.computationScheduler
                .createWorker()
                .schedule(
                    new Action0() {
                      @Override
                      public void call() {
                        final ImmutablePair<BitmapDrawable, Boolean> loaded = loadFromDisk();
                        final BitmapDrawable image = loaded.left;
                        if (image != null) {
                          subscriber.onNext(image);
                        } else {
                          subscriber.onNext(
                              returnErrorImage
                                  ? new BitmapDrawable(
                                      resources,
                                      BitmapFactory.decodeResource(
                                          resources, R.drawable.image_not_loaded))
                                  : ImageUtils.getTransparent1x1Drawable(resources));
                        }
                        subscriber.onCompleted();
                      }
                    });
          }
        });
  }

  @SuppressWarnings("static-method")
  protected ImmutablePair<BitmapDrawable, Boolean> scaleImage(
      final ImmutablePair<Bitmap, Boolean> loadResult) {
    final Bitmap bitmap = loadResult.left;
    return ImmutablePair.of(
        bitmap != null ? ImageUtils.scaleBitmapToFitDisplay(bitmap) : null, loadResult.right);
  }

  public Observable<String> waitForEndObservable(@Nullable final CancellableHandler handler) {
    if (handler != null) {
      handler.unsubscribeIfCancelled(subscription);
    }
    loading.onCompleted();
    return waitForEnd;
  }

  /**
   * Download or refresh the copy of <code>url</code> in <code>file</code>.
   *
   * @param url the url of the document
   * @param file the file to save the document in
   * @return <code>true</code> if the existing file was up-to-date, <code>false</code> otherwise
   */
  private boolean downloadOrRefreshCopy(final String url, final File file) {
    final String absoluteURL = makeAbsoluteURL(url);

    if (absoluteURL != null) {
      try {
        final HttpResponse httpResponse = Network.getRequest(absoluteURL, null, file);
        if (httpResponse != null) {
          final int statusCode = httpResponse.getStatusLine().getStatusCode();
          if (statusCode == 200) {
            LocalStorage.saveEntityToFile(httpResponse, file);
          } else if (statusCode == 304) {
            if (!file.setLastModified(System.currentTimeMillis())) {
              makeFreshCopy(file);
            }
            return true;
          }
        }
      } catch (final Exception e) {
        Log.e("HtmlImage.downloadOrRefreshCopy", e);
      }
    }
    return false;
  }

  /**
   * Make a fresh copy of the file to reset its timestamp. On some storage, it is impossible to
   * modify the modified time after the fact, in which case a brand new file must be created if we
   * want to be able to use the time as validity hint.
   *
   * <p>See Android issue 1699.
   *
   * @param file the file to refresh
   */
  private static void makeFreshCopy(final File file) {
    final File tempFile = new File(file.getParentFile(), file.getName() + "-temp");
    if (file.renameTo(tempFile)) {
      LocalStorage.copy(tempFile, file);
      FileUtils.deleteIgnoringFailure(tempFile);
    } else {
      Log.e("Could not reset timestamp of file " + file.getAbsolutePath());
    }
  }

  /**
   * Load an image from primary or secondary storage.
   *
   * @param url the image URL
   * @param pseudoGeocode the geocode or the shared name
   * @param forceKeep keep the image if it is there, without checking its freshness
   * @return A pair whose first element is the bitmap if available, and the second one is <code>true
   *     </code> if the image is present and fresh enough.
   */
  @NonNull
  private ImmutablePair<Bitmap, Boolean> loadImageFromStorage(
      final String url, @NonNull final String pseudoGeocode, final boolean forceKeep) {
    try {
      final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, false);
      final ImmutablePair<Bitmap, Boolean> image = loadCachedImage(file, forceKeep);
      if (image.right || image.left != null) {
        return image;
      }
      final File fileSec = LocalStorage.getStorageSecFile(pseudoGeocode, url, true);
      return loadCachedImage(fileSec, forceKeep);
    } catch (final Exception e) {
      Log.w("HtmlImage.loadImageFromStorage", e);
    }
    return ImmutablePair.of((Bitmap) null, false);
  }

  @Nullable
  private String makeAbsoluteURL(final String url) {
    // Check if uri is absolute or not, if not attach the connector hostname
    // FIXME: that should also include the scheme
    if (Uri.parse(url).isAbsolute()) {
      return url;
    }

    final String host = ConnectorFactory.getConnector(geocode).getHost();
    if (StringUtils.isNotEmpty(host)) {
      final StringBuilder builder = new StringBuilder("http://");
      builder.append(host);
      if (!StringUtils.startsWith(url, "/")) {
        // FIXME: explain why the result URL would be valid if the path does not start with
        // a '/', or signal an error.
        builder.append('/');
      }
      builder.append(url);
      return builder.toString();
    }

    return null;
  }

  /**
   * Load a previously saved image.
   *
   * @param file the file on disk
   * @param forceKeep keep the image if it is there, without checking its freshness
   * @return a pair with <code>true</code> in the second component if the image was there and is
   *     fresh enough or <code>false</code> otherwise, and the image (possibly <code>null</code> if
   *     the second component is <code>false</code> and the image could not be loaded, or if the
   *     second component is <code>true</code> and <code>onlySave</code> is also <code>true</code>)
   */
  @NonNull
  private ImmutablePair<Bitmap, Boolean> loadCachedImage(final File file, final boolean forceKeep) {
    if (file.exists()) {
      final boolean freshEnough =
          listId >= StoredList.STANDARD_LIST_ID
              || file.lastModified() > (System.currentTimeMillis() - (24 * 60 * 60 * 1000))
              || forceKeep;
      if (freshEnough && onlySave) {
        return ImmutablePair.of((Bitmap) null, true);
      }
      final BitmapFactory.Options bfOptions = new BitmapFactory.Options();
      bfOptions.inTempStorage = new byte[16 * 1024];
      bfOptions.inPreferredConfig = Bitmap.Config.RGB_565;
      setSampleSize(file, bfOptions);
      final Bitmap image = BitmapFactory.decodeFile(file.getPath(), bfOptions);
      if (image == null) {
        Log.e("Cannot decode bitmap from " + file.getPath());
        return ImmutablePair.of((Bitmap) null, false);
      }
      return ImmutablePair.of(image, freshEnough);
    }
    return ImmutablePair.of((Bitmap) null, false);
  }

  private void setSampleSize(final File file, final BitmapFactory.Options bfOptions) {
    // Decode image size only
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;

    BufferedInputStream stream = null;
    try {
      stream = new BufferedInputStream(new FileInputStream(file));
      BitmapFactory.decodeStream(stream, null, options);
    } catch (final FileNotFoundException e) {
      Log.e("HtmlImage.setSampleSize", e);
    } finally {
      IOUtils.closeQuietly(stream);
    }

    int scale = 1;
    if (options.outHeight > maxHeight || options.outWidth > maxWidth) {
      scale = Math.max(options.outHeight / maxHeight, options.outWidth / maxWidth);
    }
    bfOptions.inSampleSize = scale;
  }
}
Exemple #8
0
public class HtmlImage implements Html.ImageGetter {

  // This class implements an all-purpose image getter that can also be used as a ImageGetter
  // interface
  // when displaying caches. An instance mainly has three possible use cases:
  //  - If onlySave is true, getDrawable() will return null immediately and will queue the image
  // retrieval
  //    and saving in the loading subject. Downloads will start in parallel when the blocking
  //    waitForBackgroundLoading() method is called, and they can be cancelled through the given
  // handler.
  //  - If onlySave is false and the instance is called through fetchDrawable(), then an observable
  // for the
  //    given URL will be returned. This observable will emit the local copy of the image if it is
  // present,
  //    regardless of its freshness, then if needed an updated fresher copy after retrieving it from
  // the network.
  //  - If onlySave is false and the instance is used as an ImageGetter, only the final version of
  // the
  //    image will be returned.

  private static final String[] BLOCKED =
      new String[] {
        "gccounter.de",
        "gccounter.com",
        "cachercounter/?",
        "gccounter/imgcount.php",
        "flagcounter.com",
        "compteur-blog.net",
        "counter.digits.com",
        "andyhoppe",
        "besucherzaehler-homepage.de",
        "hitwebcounter.com",
        "kostenloser-counter.eu",
        "trendcounter.com",
        "hit-counter-download.com",
        "gcwetterau.de/counter"
      };
  public static final String SHARED = "shared";

  private final String geocode;
  /** on error: return large error image, if <code>true</code>, otherwise empty 1x1 image */
  private final boolean returnErrorImage;

  private final int listId;
  private final boolean onlySave;
  private final int maxWidth;
  private final int maxHeight;
  private final Resources resources;

  // Background loading
  private final PublishSubject<Observable<String>> loading = PublishSubject.create();
  final Observable<String> waitForEnd = Observable.merge(loading).publish().refCount();
  final CompositeSubscription subscription = new CompositeSubscription(waitForEnd.subscribe());
  private final Scheduler downloadScheduler =
      Schedulers.executor(
          new ThreadPoolExecutor(10, 10, 5, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()));

  public HtmlImage(
      final String geocode,
      final boolean returnErrorImage,
      final int listId,
      final boolean onlySave) {
    this.geocode = geocode;
    this.returnErrorImage = returnErrorImage;
    this.listId = listId;
    this.onlySave = onlySave;

    Point displaySize = Compatibility.getDisplaySize();
    this.maxWidth = displaySize.x - 25;
    this.maxHeight = displaySize.y - 25;
    this.resources = CgeoApplication.getInstance().getResources();
  }

  @Nullable
  @Override
  public BitmapDrawable getDrawable(final String url) {
    final Observable<BitmapDrawable> drawable = fetchDrawable(url);
    if (onlySave) {
      loading.onNext(
          drawable.map(
              new Func1<BitmapDrawable, String>() {
                @Override
                public String call(final BitmapDrawable bitmapDrawable) {
                  return url;
                }
              }));
      return null;
    } else {
      return drawable.toBlockingObservable().lastOrDefault(null);
    }
  }

  public Observable<BitmapDrawable> fetchDrawable(final String url) {
    final boolean shared = url.contains("/images/icons/icon_");
    final String pseudoGeocode = shared ? SHARED : geocode;

    final Observable<Pair<BitmapDrawable, Boolean>> loadFromDisk =
        Observable.create(
                new OnSubscribeFunc<Pair<BitmapDrawable, Boolean>>() {
                  @Override
                  public Subscription onSubscribe(
                      final Observer<? super Pair<BitmapDrawable, Boolean>> observer) {
                    final Pair<Bitmap, Boolean> loadResult =
                        loadImageFromStorage(url, pseudoGeocode, shared);
                    final Bitmap bitmap = loadResult.getLeft();
                    observer.onNext(
                        new ImmutablePair<BitmapDrawable, Boolean>(
                            bitmap != null ? ImageUtils.scaleBitmapToFitDisplay(bitmap) : null,
                            loadResult.getRight()));
                    observer.onCompleted();
                    return Subscriptions.empty();
                  }
                })
            .subscribeOn(Schedulers.computation());

    final Observable<BitmapDrawable> downloadAndSave =
        Observable.create(
                new OnSubscribeFunc<BitmapDrawable>() {
                  @Override
                  public Subscription onSubscribe(final Observer<? super BitmapDrawable> observer) {
                    final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, true);
                    if (url.startsWith("data:image/")) {
                      if (url.contains(";base64,")) {
                        saveBase64ToFile(url, file);
                      } else {
                        Log.e("HtmlImage.getDrawable: unable to decode non-base64 inline image");
                        observer.onCompleted();
                        return Subscriptions.empty();
                      }
                    } else {
                      if (subscription.isUnsubscribed() || downloadOrRefreshCopy(url, file)) {
                        // The existing copy was fresh enough or we were unsubscribed earlier.
                        observer.onCompleted();
                        return Subscriptions.empty();
                      }
                    }
                    if (onlySave) {
                      observer.onCompleted();
                    } else {
                      loadFromDisk
                          .map(
                              new Func1<Pair<BitmapDrawable, Boolean>, BitmapDrawable>() {
                                @Override
                                public BitmapDrawable call(
                                    final Pair<BitmapDrawable, Boolean> loadResult) {
                                  final BitmapDrawable image = loadResult.getLeft();
                                  if (image != null) {
                                    return image;
                                  } else {
                                    return returnErrorImage
                                        ? new BitmapDrawable(
                                            resources,
                                            BitmapFactory.decodeResource(
                                                resources, R.drawable.image_not_loaded))
                                        : getTransparent1x1Image(resources);
                                  }
                                }
                              })
                          .subscribe(observer);
                    }
                    return Subscriptions.empty();
                  }
                })
            .subscribeOn(downloadScheduler);

    if (StringUtils.isBlank(url) || isCounter(url)) {
      return Observable.from(getTransparent1x1Image(resources));
    }

    return loadFromDisk.switchMap(
        new Func1<Pair<BitmapDrawable, Boolean>, Observable<? extends BitmapDrawable>>() {
          @Override
          public Observable<? extends BitmapDrawable> call(
              final Pair<BitmapDrawable, Boolean> loadResult) {
            final BitmapDrawable bitmap = loadResult.getLeft();
            if (loadResult.getRight()) {
              return Observable.from(bitmap);
            }
            return bitmap != null && !onlySave
                ? downloadAndSave.startWith(bitmap)
                : downloadAndSave;
          }
        });
  }

  public void waitForBackgroundLoading(@Nullable final CancellableHandler handler) {
    if (handler != null) {
      handler.unsubscribeIfCancelled(subscription);
    }
    loading.onCompleted();
    waitForEnd.toBlockingObservable().lastOrDefault(null);
  }

  /**
   * Download or refresh the copy of <code>url</code> in <code>file</code>.
   *
   * @param url the url of the document
   * @param file the file to save the document in
   * @return <code>true</code> if the existing file was up-to-date, <code>false</code> otherwise
   */
  private boolean downloadOrRefreshCopy(final String url, final File file) {
    final String absoluteURL = makeAbsoluteURL(url);

    if (absoluteURL != null) {
      try {
        final HttpResponse httpResponse = Network.getRequest(absoluteURL, null, file);
        if (httpResponse != null) {
          final int statusCode = httpResponse.getStatusLine().getStatusCode();
          if (statusCode == 200) {
            LocalStorage.saveEntityToFile(httpResponse, file);
          } else if (statusCode == 304) {
            if (!file.setLastModified(System.currentTimeMillis())) {
              makeFreshCopy(file);
            }
            return true;
          }
        }
      } catch (Exception e) {
        Log.e("HtmlImage.downloadOrRefreshCopy", e);
      }
    }
    return false;
  }

  private static void saveBase64ToFile(final String url, final File file) {
    // TODO: when we use SDK level 8 or above, we can use the streaming version of the base64
    // Android utilities.
    OutputStream out = null;
    try {
      out = new FileOutputStream(file);
      out.write(Base64.decode(StringUtils.substringAfter(url, ";base64,"), Base64.DEFAULT));
    } catch (final IOException e) {
      Log.e("HtmlImage.saveBase64ToFile: cannot write file for decoded inline image", e);
    } finally {
      IOUtils.closeQuietly(out);
    }
  }

  /**
   * Make a fresh copy of the file to reset its timestamp. On some storage, it is impossible to
   * modify the modified time after the fact, in which case a brand new file must be created if we
   * want to be able to use the time as validity hint.
   *
   * <p>See Android issue 1699.
   *
   * @param file the file to refresh
   */
  private static void makeFreshCopy(final File file) {
    final File tempFile = new File(file.getParentFile(), file.getName() + "-temp");
    if (file.renameTo(tempFile)) {
      LocalStorage.copy(tempFile, file);
      FileUtils.deleteIgnoringFailure(tempFile);
    } else {
      Log.e("Could not reset timestamp of file " + file.getAbsolutePath());
    }
  }

  private BitmapDrawable getTransparent1x1Image(final Resources res) {
    return new BitmapDrawable(
        res, BitmapFactory.decodeResource(resources, R.drawable.image_no_placement));
  }

  /**
   * Load an image from primary or secondary storage.
   *
   * @param url the image URL
   * @param pseudoGeocode the geocode or the shared name
   * @param forceKeep keep the image if it is there, without checking its freshness
   * @return <code>true</code> if the image was there and is fresh enough, <code>false</code>
   *     otherwise
   */
  @NonNull
  private Pair<Bitmap, Boolean> loadImageFromStorage(
      final String url, final String pseudoGeocode, final boolean forceKeep) {
    try {
      final File file = LocalStorage.getStorageFile(pseudoGeocode, url, true, false);
      final Pair<Bitmap, Boolean> image = loadCachedImage(file, forceKeep);
      if (image.getRight() || image.getLeft() != null) {
        return image;
      }
      final File fileSec = LocalStorage.getStorageSecFile(pseudoGeocode, url, true);
      return loadCachedImage(fileSec, forceKeep);
    } catch (Exception e) {
      Log.w("HtmlImage.loadImageFromStorage", e);
    }
    return new ImmutablePair<Bitmap, Boolean>(null, false);
  }

  @Nullable
  private String makeAbsoluteURL(final String url) {
    // Check if uri is absolute or not, if not attach the connector hostname
    // FIXME: that should also include the scheme
    if (Uri.parse(url).isAbsolute()) {
      return url;
    }

    final String host = ConnectorFactory.getConnector(geocode).getHost();
    if (StringUtils.isNotEmpty(host)) {
      final StringBuilder builder = new StringBuilder("http://");
      builder.append(host);
      if (!StringUtils.startsWith(url, "/")) {
        // FIXME: explain why the result URL would be valid if the path does not start with
        // a '/', or signal an error.
        builder.append('/');
      }
      builder.append(url);
      return builder.toString();
    }

    return null;
  }

  /**
   * Load a previously saved image.
   *
   * @param file the file on disk
   * @param forceKeep keep the image if it is there, without checking its freshness
   * @return a pair with <code>true</code> if the image was there and is fresh enough or <code>false
   *     </code> otherwise, and the image (possibly <code>null</code> if the first component is
   *     <code>false</code> and the image could not be loaded, or if the first component is <code>
   *     true</code> and <code>onlySave</code> is also <code>true</code>)
   */
  @NonNull
  private Pair<Bitmap, Boolean> loadCachedImage(final File file, final boolean forceKeep) {
    if (file.exists()) {
      final boolean freshEnough =
          listId >= StoredList.STANDARD_LIST_ID
              || file.lastModified() > (new Date().getTime() - (24 * 60 * 60 * 1000))
              || forceKeep;
      if (onlySave) {
        return new ImmutablePair<Bitmap, Boolean>(null, true);
      }
      final BitmapFactory.Options bfOptions = new BitmapFactory.Options();
      bfOptions.inTempStorage = new byte[16 * 1024];
      bfOptions.inPreferredConfig = Bitmap.Config.RGB_565;
      setSampleSize(file, bfOptions);
      final Bitmap image = BitmapFactory.decodeFile(file.getPath(), bfOptions);
      if (image == null) {
        Log.e("Cannot decode bitmap from " + file.getPath());
        return new ImmutablePair<Bitmap, Boolean>(null, false);
      }
      return new ImmutablePair<Bitmap, Boolean>(image, freshEnough);
    }
    return new ImmutablePair<Bitmap, Boolean>(null, false);
  }

  private void setSampleSize(final File file, final BitmapFactory.Options bfOptions) {
    // Decode image size only
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;

    BufferedInputStream stream = null;
    try {
      stream = new BufferedInputStream(new FileInputStream(file));
      BitmapFactory.decodeStream(stream, null, options);
    } catch (FileNotFoundException e) {
      Log.e("HtmlImage.setSampleSize", e);
    } finally {
      IOUtils.closeQuietly(stream);
    }

    int scale = 1;
    if (options.outHeight > maxHeight || options.outWidth > maxWidth) {
      scale = Math.max(options.outHeight / maxHeight, options.outWidth / maxWidth);
    }
    bfOptions.inSampleSize = scale;
  }

  private static boolean isCounter(final String url) {
    for (String entry : BLOCKED) {
      if (StringUtils.containsIgnoreCase(url, entry)) {
        return true;
      }
    }
    return false;
  }
}