Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request: Hooks to know when image loading started & finished for all requests #1440

Open
startthread opened this issue Aug 31, 2016 · 22 comments

Comments

@startthread
Copy link

glide 3.7.0

Not sure if we already have way to know when image loading started and finished for all the request, even when it loads from cache.

I would like to use using idling resource for android testing which make sure images are loaded and app is idle now.

@TWiStErRob
Copy link
Collaborator

Heh, I just mentioned this to Sam yesterday. I settled on reflective access to Glide.engine.engineJobFactory.*service, but I didn't test it yet. Let me see how it works now.

@TWiStErRob

This comment was marked as outdated.

@dral3x
Copy link

dral3x commented Mar 16, 2017

Hi there,
thanks for sharing this hack. Is it possible also to see the imports for this class?
I'm not able to find ReflectionTools class and trySetAccessible, tryFindDeclaredField methods.

Thanks

@TWiStErRob

This comment was marked as outdated.

@dral3x
Copy link

dral3x commented Mar 16, 2017

You're right for the implementation of ReflectionTools. I'm not used to use reflection but I made them easily.

Thanks for letting us know. I'll looking forward for a more correct implementation. Thanks!

@TWiStErRob
Copy link
Collaborator

Hi @dral3x, here's the updated version. It's crazy complex, I hope it works for you as well. Watch out for the package declarations, due to visibility of Glide internals it was easier to put some classes there, then reflecting on everything. I'm using my own implementation of SLF4J bindings similar to https://www.slf4j.org/android/, but that should work too; or rewrite the logs to match your own logging framework. We need to register it before any Glide loads are launched, otherwise it may not pick up on some loads. I should probably distribute it as a gradle dep, if I have time in the future.

public class MyActivityRule<T extends Activity> extends ActivityTestRule<T> {
	private final GlideIdlingResource glideIdler = new GlideIdlingResource();

	// super ctors omitted

	@Override protected void beforeActivityLaunched() {
		Espresso.registerIdlingResources(glideIdler);
		super.beforeActivityLaunched();
	}

	@Override protected void afterActivityFinished() {
		super.afterActivityFinished();
		Espresso.unregisterIdlingResources(glideIdler);
	}
}
package net.twisterrob.android.test.espresso.idle;

import java.lang.reflect.Field;

import org.slf4j.*;

import android.support.annotation.Nullable;
import android.support.test.InstrumentationRegistry;

import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.*;

public class GlideIdlingResource extends AsyncIdlingResource {
	private static final Logger LOG = LoggerFactory.getLogger(GlideIdlingResource.class);
	private static final Field mEngine = getEngineField();

	private final Runnable callTransitionToIdle = new Runnable() {
		@Override public void run() {
			transitionToIdle();
		}
	};
	private EngineIdleWatcher watcher;
	private Engine currentEngine;

	@Override public String getName() {
		return "Glide";
	}

	@Override protected boolean isIdle() {
		// Glide is a singleton, hence Engine should be too; just lazily initialize when needed.
		// In case Glide is replaced, this will still work.
		Engine engine = getEngine();
		if (currentEngine != engine) {
			if (watcher != null) {
				watcher.unsubscribe(callTransitionToIdle);
			}
			EngineIdleWatcher oldWatcher = watcher;
			watcher = new EngineIdleWatcher(engine);
			watcher.setLogEvents(isVerbose());
			if (currentEngine != null) {
				LOG.warn("Engine changed from {}({}) to {}({})", currentEngine, oldWatcher, engine, watcher);
			}
			currentEngine = engine;
		}
		return isIdleCore();
	}

	private boolean isIdleCore() {
		return watcher.isIdle();
	}

	@Override protected void waitForIdleAsync() {
		watcher.subscribe(callTransitionToIdle);
	}

	@Override protected void transitionToIdle() {
		watcher.unsubscribe(callTransitionToIdle);
		super.transitionToIdle();
	}

	private @Nullable Engine getEngine() {
		try {
			Glide glide = Glide.get(InstrumentationRegistry.getTargetContext());
			return (Engine)mEngine.get(glide);
		} catch (IllegalAccessException ex) {
			throw new IllegalStateException("Glide Engine cannot be found", ex);
		}
	}

	private static Field getEngineField() {
		try {
			Field field = Glide.class.getDeclaredField("engine");
			field.setAccessible(true);
			return field;
		} catch (Exception ex) {
			throw new IllegalStateException("Glide Engine cannot be found", ex);
		}
	}
}
package com.bumptech.glide.load.engine;

import java.util.*;

import org.slf4j.*;

import com.bumptech.glide.request.ResourceCallback;

import static net.twisterrob.java.utils.ReflectionTools.*;

/**
 * This class is the bridge between package private stuff and the world, don't try to inline it.
 */
public class EngineIdleWatcher implements EngineExternalLifecycle.PhaseCallbacks {
	private static final Logger LOG = LoggerFactory.getLogger("EngineIdleWatcher");
	private final Set<Runnable> idleCallbacks = new HashSet<>();
	private final EngineExternalLifecycle lifecycle;

	private boolean logEvents = false;

	public EngineIdleWatcher(Engine engine) {
		lifecycle = new EngineExternalLifecycle(engine, this);
	}

	public void setLogEvents(boolean logEvents) {
		this.logEvents = logEvents;
	}
	public void subscribe(Runnable callback) {
		idleCallbacks.add(callback);
	}

	public void unsubscribe(Runnable callback) {
		idleCallbacks.remove(callback);
	}

	public boolean isIdle() {
		Collection<EngineJob> jobs = lifecycle.getJobs();
		Collection<? extends ResourceCallback> active = lifecycle.getActive();
		//LOG.trace("{}/{}: active={}", this, lifecycle,  active.size());
		return jobs.isEmpty() && active.isEmpty();
	}

	private void tryToCallBack() {
		if (isIdle()) {
			for (Runnable callback : idleCallbacks) {
				callback.run();
			}
		}
	}

	@Override public void starting(Engine engine, EngineKey key, EngineJob job) {
		if (logEvents) {
			LOG.trace("{}.starting {}: {}", this, job, id(key));
		}
	}
	@Override public void finishing(Engine engine, EngineKey key, EngineJob job) {
		if (logEvents) {
			LOG.trace("{}.finishing {}: {}", this, job, id(key));
		}
	}
	@Override public void cancelled(Engine engine, EngineKey key, EngineJob job) {
		if (logEvents) {
			LOG.trace("{}.cancelled {}: {}", this, job, id(key));
		}
		tryToCallBack();
	}
	@Override public void loadSuccess(Engine engine, EngineKey key, EngineJob job) {
		if (logEvents) {
			LOG.trace("{}.loadSuccess {}: {}", this, job, id(key));
		}
		tryToCallBack();
	}
	@Override public void loadFailure(Engine engine, EngineKey key, EngineJob job) {
		if (logEvents) {
			LOG.trace("{}.loadFailure {}: {}", this, job, id(key));
		}
		tryToCallBack();
	}

	private Object id(EngineKey key) {
		return get(key, "id") + "[" + get(key, "width") + "x" + get(key, "height") + "]";
	}
}
package com.bumptech.glide.load.engine;

import java.lang.reflect.Field;
import java.util.*;

import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

import android.support.annotation.*;

import com.bumptech.glide.load.Key;
import com.bumptech.glide.request.ResourceCallback;
import com.bumptech.glide.util.Util;

import static net.twisterrob.java.utils.ReflectionTools.*;

public class EngineExternalLifecycle {
	private static final Field mJobs = getJobsField();
	private static final Field mCbs = getCallbacksField();

	private final PhaseCallbacks callback;
	private final Engine engine;
	private final EngineJobsReplacement replacementJobs = new EngineJobsReplacement();
	private final Collection<LoadEndListener> endListeners = new HashSet<>();

	public EngineExternalLifecycle(@NonNull Engine engine, PhaseCallbacks callback) {
		this.callback = callback;
		this.engine = engine;
		associate();
	}

	public Collection<EngineJob> getJobs() {
		return Collections.unmodifiableCollection(replacementJobs.values());
	}
	public Collection<ResourceCallback> getActive() {
		return Collections.<ResourceCallback>unmodifiableCollection(endListeners);
	}

	private void starting(EngineKey key, EngineJob job) {
		LoadEndListener endListener = setSignUp(key, job);
		assertTrue(endListeners.add(endListener));
		assertThat(replacementJobs, hasEntry((Key)key, job));
		callback.starting(engine, key, job);
		//noinspection ConstantConditions it's a primitive type, they won't be null
		if (job.isCancelled()
				|| (Boolean)get(job, "hasResource")
				|| (Boolean)get(job, "hasException")) {
			// catch up in case they're already done when we're created
			finishing(key, job);
			// call the corresponding callback method
			job.addCallback(endListener);
		}
	}

	private void finishing(EngineKey key, EngineJob job) {
		assertThat(replacementJobs, not(hasKey((Key)key)));
		assertThat(replacementJobs, not(hasValue(job)));
		assertThat(endListeners, hasItem(getSignUp(job)));
		if (job.isCancelled()) {
			assertTrue(endListeners.remove(getSignUp(job)));
			callback.cancelled(engine, key, job);
		} else {
			callback.finishing(engine, key, job);
		}
	}

	private void loadSuccess(LoadEndListener signup) {
		assertTrue(endListeners.remove(signup));
		callback.loadSuccess(engine, signup.key, signup.job);
	}

	private void loadFailure(LoadEndListener signup) {
		assertTrue(endListeners.remove(signup));
		callback.loadFailure(engine, signup.key, signup.job);
	}

	/** {@code Engine.jobs = new EngineJobsReplacement(Engine.jobs)} */
	private void associate() {
		try {
			@SuppressWarnings("unchecked")
			Map<Key, EngineJob> original = (Map<Key, EngineJob>)mJobs.get(engine);
			if (original instanceof EngineJobsReplacement) {
				EngineJobsReplacement replacement = (EngineJobsReplacement)original;
				throw new IllegalStateException(
						engine + " already has an external lifecycle: " + replacement.getAssociation());
			}
			assertThat(replacementJobs, is(anEmptyMap()));
			replacementJobs.putAll(original);
			mJobs.set(engine, replacementJobs);
		} catch (Exception ex) {
			throw new IllegalStateException("Cannot hack Engine.jobs", ex);
		}
	}

	/** {@code job.cbs += new LoadEndListener()} */
	private LoadEndListener setSignUp(EngineKey key, EngineJob job) {
		Util.assertMainThread();
		try {
			@SuppressWarnings("unchecked")
			List<ResourceCallback> cbs = (List<ResourceCallback>)mCbs.get(job);
			if (cbs instanceof ExtraItemList) {
				throw new IllegalStateException(job + " already being listened to by " + cbs);
			}
			LoadEndListener extra = new LoadEndListener(key, job);
			cbs = new ExtraItemList(cbs, extra);
			mCbs.set(job, cbs);
			return extra;
		} catch (IllegalAccessException ex) {
			throw new IllegalStateException("Cannot hack EngineJob.cbs", ex);
		}
	}

	/** {@code job.cbs.iterator().last()} */
	private LoadEndListener getSignUp(EngineJob job) {
		Util.assertMainThread();
		try {
			@SuppressWarnings("unchecked")
			List<ResourceCallback> cbs = (List<ResourceCallback>)mCbs.get(job);
			if (cbs instanceof ExtraItemList) {
				return (LoadEndListener)((ExtraItemList)cbs).extra;
			} else {
				throw new IllegalStateException(job + " doesn't have an end listener");
			}
		} catch (IllegalAccessException ex) {
			throw new IllegalStateException("Cannot hack EngineJob.cbs", ex);
		}
	}

	private static Field getJobsField() {
		try {
			Field field;
			field = Engine.class.getDeclaredField("jobs");
			field.setAccessible(true);
			return field;
		} catch (Exception ex) {
			throw new IllegalStateException("Glide Engine jobs cannot be found", ex);
		}
	}

	private static Field getCallbacksField() {
		try {
			Field field;
			field = EngineJob.class.getDeclaredField("cbs");
			field.setAccessible(true);
			return field;
		} catch (Exception ex) {
			throw new IllegalStateException("Glide EngineJobs callbacks cannot be found", ex);
		}
	}

	@Override public String toString() {
		return String.format(Locale.ROOT, "%s jobs=%d, listeners=%d, callback=%s",
				engine, replacementJobs.size(), endListeners.size(), callback);
	}

	/**
	 * Appends and extra item to the end of the list, but only when iterating. Size and other queries won't report it.
	 * This is only useful with {@link EngineJob}s, because we know that only {@link #add}, {@link #remove},
	 * {@link #isEmpty} and {@link #iterator} are going to be called.
	 */
	private class ExtraItemList extends ArrayList<ResourceCallback> {
		private final ResourceCallback extra;
		public ExtraItemList(Collection<ResourceCallback> callbacks, ResourceCallback extra) {
			super(callbacks);
			this.extra = extra;
		}
		@NonNull @Override public Iterator<ResourceCallback> iterator() {
			Iterator<ResourceCallback> extraIt =
					android.support.test.espresso.core.deps.guava.collect.Iterators.singletonIterator(extra);
			return android.support.test.espresso.core.deps.guava.collect.Iterators.concat(super.iterator(), extraIt);
		}
	}

	/**
	 * Callbacks whenever jobs are added or removed. This helps to "modify" the code of Engine externally.
	 */
	private class EngineJobsReplacement extends HashMap<Key, EngineJob> {
		@Override public EngineJob put(Key key, EngineJob value) {
			assertNull(super.put(key, value));
			starting((EngineKey)key, value);
			return null;
		}
		@Override public EngineJob remove(Object key) {
			EngineJob removed = super.remove(key);
			finishing((EngineKey)key, removed);
			return removed;
		}
		public EngineExternalLifecycle getAssociation() {
			return EngineExternalLifecycle.this;
		}
	}

	/**
	 * Called back at the end of a job when all other resources are notified.
	 */
	private class LoadEndListener implements ResourceCallback {
		private final EngineKey key;
		private final EngineJob job;
		public LoadEndListener(EngineKey key, EngineJob job) {
			this.key = key;
			this.job = job;
		}

		@Override public void onResourceReady(Resource<?> resource) {
			// this "target" won't ever be cleared, so let's clean up real quick after ourselves
			((EngineResource<?>)resource).release();
			loadSuccess(this);
		}

		@Override public void onException(Exception e) {
			loadFailure(this);
		}

		@Override public String toString() {
			return job + ": " + get(key, "id")
					+ "[" + get(key, "width") + "x" + get(key, "height") + "]";
		}
	}

	@UiThread
	public interface PhaseCallbacks {
		/**
		 * Job created, but no callbacks are added yet, and the job will be started right after this.
		 * @see Engine#load
		 */
		//EngineJob engineJob = engineJobFactory.build(key, isMemoryCacheable);
		//jobs.put(key, engineJob); // starting
		//engineJob.addCallback(cb);
		//engineJob.start(runnable);
		void starting(Engine engine, EngineKey key, EngineJob job);

		/**
		 * Job is finishing, it already has a resource or an exception,
		 * but the result is not broadcast yet to the callbacks.
		 * Either {@link #loadSuccess} or {@link #loadFailure} will be called right after this.
		 * @see Engine#onEngineJobComplete
		 * @see EngineJob#handleResultOnMainThread()
		 * @see EngineJob#handleExceptionOnMainThread()
		 */
		//hasResource = true; or hasException = true;
		//listener.onEngineJobComplete(key, ?); -> jobs.remove(key);
		void finishing(Engine engine, EngineKey key, EngineJob job);

		/**
		 * Job is cancelled, it has no resource nor exception.
		 * No more interaction are expected with this job after this.
		 * @see EngineJob#cancel()
		 * @see Engine#onEngineJobCancelled
		 */
		//isCancelled = true;
		//listener.onEngineJobCancelled(this, key); -> jobs.remove(key);
		void cancelled(Engine engine, EngineKey key, EngineJob job);

		/**
		 * All the callbacks have been notified for {@link ResourceCallback#onResourceReady}.
		 * Load is considered fully finished, resources are delivered to targets.
		 * No further interaction will, not even when be clearing the target,
		 * because the job already has resource or exception and removeCallback won't call cancel.
		 * @see EngineJob#handleResultOnMainThread()
		 */
		//hasResource = true;
		//engineResource.acquire();
		//listener.onEngineJobComplete(key, engineResource); -> jobs.remove(key);
		//for (ResourceCallback cb : cbs) {
		//	engineResource.acquire(); // this is released in LoadEndListener
		//	cb.onResourceReady(engineResource);
		//}
		//engineResource.release();
		void loadSuccess(Engine engine, EngineKey key, EngineJob job);

		/**
		 * All the callbacks have been notified for {@link ResourceCallback#onException}.
		 * Load is considered fully finished, exception is delivered to targets.
		 * No further interaction will, not even when be clearing the target,
		 * because the job already has resource or exception and removeCallback won't call cancel.
		 * @see EngineJob#handleExceptionOnMainThread()
		 */
		//hasException = true;
		//listener.onEngineJobComplete(key, null); -> jobs.remove(key);
		//for (ResourceCallback cb : cbs) cb.onException(exception);
		void loadFailure(Engine engine, EngineKey key, EngineJob job);
	}
}
package net.twisterrob.android.test.espresso.idle;

import org.slf4j.*;

import android.support.annotation.MainThread;
import android.support.test.espresso.IdlingResource;

public abstract class AsyncIdlingResource implements IdlingResource {
	private final Logger LOG = LoggerFactory.getLogger(getClass());

	private ResourceCallback resourceCallback;
	private boolean verbose = false;

	protected AsyncIdlingResource() {
		//beVerbose();
	}

	public AsyncIdlingResource beVerbose() {
		verbose = true;
		return this;
	}

	protected boolean isVerbose() {
		return verbose;
	}

	@MainThread
	@Override public final boolean isIdleNow() {
		boolean idle = isIdle();
		if (verbose || !idle) {
			LOG.trace("{}.isIdleNow: {}", getName(), idle);
		}
		if (idle) {
			transitionToIdle(false);
		} else {
			waitForIdleAsync();
		}
		return idle;
	}

	//@AnyThread, don't enable yet, there are still annoying warnings
	protected void transitionToIdle() {
		transitionToIdle(true);
	}

	private void transitionToIdle(boolean log) {
		if (log) {
			LOG.trace("{}.onTransitionToIdle with {}", getName(), resourceCallback);
		}
		if (resourceCallback != null) {
			resourceCallback.onTransitionToIdle();
		}
	}

	@MainThread
	protected abstract boolean isIdle();
	@MainThread
	protected abstract void waitForIdleAsync();

	public ResourceCallback getIdleTransitionCallback() {
		return resourceCallback;
	}

	@MainThread
	@Override public void registerIdleTransitionCallback(ResourceCallback resourceCallback) {
		this.resourceCallback = resourceCallback;
	}
}
package net.twisterrob.java.utils;

import java.lang.reflect.*;
import javax.annotation.*;
import org.slf4j.*;

public class ReflectionTools {
	private static final Logger LOG = LoggerFactory.getLogger(ReflectionTools.class);

	@SuppressWarnings("unchecked")
	public static <T> T get(@Nonnull Object object, @Nonnull String fieldName) {
		try {
			Field field = findDeclaredField(object.getClass(), fieldName);
			field.setAccessible(true);
			return (T)field.get(object);
		} catch (Exception ex) {
			//noinspection ConstantConditions prevent NPE when object is null, even though it was declared not null
			Class<?> clazz = object != null? object.getClass() : null;
			LOG.warn("Cannot read field {} of ({}){}", fieldName, clazz, object, ex);
		}
		return null;
	}
}

@poldz123
Copy link

I am having the same issue here seems a very complex hack, but is there a way to just set all of the ExecutorService as SerialExecutor So it is going to be synchronize with espresso?

@TWiStErRob
Copy link
Collaborator

@poldz123 hmm, that may also work.

You can use GlideModule.applyOptions to call GlideBuilder.setResizeService/setDiskCacheService.

Note that my first shared impl here is hacking those services, but it didn't work, because those become idle, before the actual Targets receive the resources; so I couldn't check if an image was actually loaded. But please do try, and let me know if it works.

@poldz123
Copy link

@TWiStErRob Yes this does work by specifying the resizeService and DiskCacheService with a custom executor service. Just take note that both must have a different reference to the executor service.

@TWiStErRob
Copy link
Collaborator

Does Espresso do automatic idle detection on all executor services?

@poldz123
Copy link

poldz123 commented Mar 21, 2017

Yes it does wait on all of the executor as long you have different instances of it.

Having a wrapper for the ExecutorService will let you have control on it. Which you can then add a CountingIdlingResource to the wrapper to let espresso wait until glide has finishes/idle.

https://github.com/google/guava/blob/master/guava/src/com/google/common/util/concurrent/WrappingExecutorService.java

@TWiStErRob
Copy link
Collaborator

Ah, so it's not automatic, that sounds like my first solution, but without reflection and manual queue counting. Thanks for sharing it, it's a valuable idea!

@sumio
Copy link

sumio commented Aug 5, 2018

As of Espresso 3.0.0, IdlingThreadPoolExecutor is available.

By using it, the following code makes Espresso wait until loading images has finished!

// must belong to this package because GlideExecutor(ExecutorService) has package private scope...
package com.bumptech.glide.load.engine.executor;

@GlideModule
public class IdlingGlideModule extends AppGlideModule {

    @SuppressLint("VisibleForTests")
    @Override
    public void applyOptions(Context context, GlideBuilder builder) {
        builder.setDiskCacheExecutor(new GlideExecutor(
                new IdlingThreadPoolExecutor("GlideDiskCacheExecutor",
                        1,
                        1,
                        0,
                        TimeUnit.MICROSECONDS,
                        new PriorityBlockingQueue<>(),
                        // GlideExecutor.DefaultThreadFactory is private and can't access.
                        Executors.defaultThreadFactory())
        ));
        builder.setSourceExecutor(new GlideExecutor(
                new IdlingThreadPoolExecutor("GlideSourceExecutor",
                        GlideExecutor.calculateBestThreadCount(),
                        GlideExecutor.calculateBestThreadCount(),
                        0,
                        TimeUnit.MICROSECONDS,
                        new PriorityBlockingQueue<>(),
                        // GlideExecutor.DefaultThreadFactory is private and can't access.
                        Executors.defaultThreadFactory())
        ));
    }
}

@TWiStErRob
I'd like to avoid defining my class in com.bumptech.glide.load.engine.executor if possible.
Is it possible to relax access modifiers of GlideExecutor?

@sjudd
Copy link
Collaborator

sjudd commented Aug 9, 2018

See

.

I believe we haven't released this publicly, but if there's demand we could work toward that. Or if you're interested in sending a pull request to add the build configuration and target for a mocks/fakes library for Glide I'd appreciate that.

@sumio
Copy link

sumio commented Aug 13, 2018

@sjudd Thanks for your reply.
I'm interesting in sending the pull request!
Do you mean that the PR creates new artifact for debug build type which provides GlideExecutore.newTestExecutor(ExecutorService) like MockGlideExecutor.newTestExecutor()?
If my understand is correct, i'll try later.

@sjudd
Copy link
Collaborator

sjudd commented Aug 14, 2018

Yeah you'd add a new subproject that exposes a set of Fakes for testing Glide. You could probably put it at the top level and call it "Fakes".

@TWiStErRob
Copy link
Collaborator

@sumio NB: when I wrote my GlideIdlingResource above I also started with the executor, but that wasn't enough to get functioning UI tests. Only the heavy I/O is pushed to the background thread, but the delivery to the main thread needs to happen before we can actually verify if an image is displayed for example. Idling on executor alone will result in flaky tests. The "I'm idle" notification needs to happen after the onResourceReady of the Target finished executing.

@AnwarShahriar
Copy link
Contributor

@sumio are you working on the PR, this feature will sure be useful for espresso tests.

@sumio
Copy link

sumio commented Jan 9, 2019

@AnwarShahriar @sjudd @TWiStErRob
I can't touch this issue because I've been busy with my work since that time.
I'm really sorry, though it was me to suggest sending PR.

@agrvaibhav
Copy link

Sorry for hijacking the thread but is there a way to get a callback in non-reflection way, when all images are loaded?

@technoir42
Copy link
Contributor

technoir42 commented Jan 21, 2020

I'd like to share my solution that looks clean and seems to be working well:

  1. Replaced all calls to GlideApp with my own wrapper so that I would have a single place where GlideRequest.into is called.
  2. Registered a singleton CountingIdlingResource which would track requests in progress:
val GLIDE_REQUEST_COUNTER = CountingIdlingResource("glide-requests", true)
IdlingRegistry.getInstance().register(GLIDE_REQUEST_COUNTER)

You might consider having one idling resource per Activity/Fragment/RequestManager so that your tests wouldn't have to wait for screens in background but that's gonna be trickier to manage.

  1. Implemented a custom Target which would increment/decrement the idling resource and delegate to the real target:
class IdlingResourceTarget<T>(
    private val delegate: Target<T>,
    idlingResource: CountingIdlingResource
) : Target<T> {

    private var finished = false
    private val idlingResourceWrapper = IdlingResourceWrapper(idlingResource)
    private val sizeCallback = object : SizeReadyCallback {
        override fun onSizeReady(width: Int, height: Int) {
            // Ignore if onResourceReady, onLoadFailed or onLoadCleared has been called already
            if (!finished) {
                idlingResourceWrapper.isLoading = true
            }
            delegate.removeCallback(this)
        }
    }

    override fun onLoadStarted(placeholder: Drawable?) {
        delegate.onLoadStarted(placeholder)
        finished = false
        // onLoadStarted can be called when the corresponding view is not visible on the screen
        // so we wait for the view to be laid out.
        delegate.getSize(sizeCallback)
    }

    override fun onLoadFailed(errorDrawable: Drawable?) {
        delegate.onLoadFailed(errorDrawable)
        finished = true
        idlingResourceWrapper.isLoading = false
    }

    override fun onResourceReady(resource: T, transition: Transition<in T>?) {
        delegate.onResourceReady(resource, transition)
        finished = true
        idlingResourceWrapper.isLoading = false
    }

    override fun onLoadCleared(placeholder: Drawable?) {
        delegate.onLoadCleared(placeholder)
        finished = true
        idlingResourceWrapper.isLoading = false
    }

    override fun getRequest(): Request? {
        return delegate.request
    }

    override fun setRequest(request: Request?) {
        delegate.request = request
    }

    override fun getSize(cb: SizeReadyCallback) {
        delegate.getSize(cb)
    }

    override fun removeCallback(cb: SizeReadyCallback) {
        delegate.removeCallback(cb)
    }

    override fun onStart() {
        delegate.onStart()
    }

    override fun onStop() {
        delegate.onStop()
    }

    override fun onDestroy() {
        delegate.onDestroy()
    }

    private class IdlingResourceWrapper(private val idlingResource: CountingIdlingResource) {
        var isLoading: Boolean = false
            set(value) {
                if (field != value) {
                    field = value
                    if (value) {
                        idlingResource.increment()
                    } else {
                        idlingResource.decrement()
                    }
                }
            }
    }
}
  1. Replaced all the calls to GlideRequest#into(ImageView) with:
request.into(IdlingResourceTarget(DrawableImageViewTarget(view), GLIDE_REQUEST_COUNTER))
  1. Because android:scaleType handling logic is implemented inside RequestManager#into(ImageView) I had to re-implement it myself.

Hope this helps.

@technoir42
Copy link
Contributor

Updated the code in #1440 (comment) with a bugfix for some corner cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants