CountingIdlingResource.java

/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.test.espresso.idling;

import android.os.SystemClock;
import android.text.TextUtils;
import android.util.Log;
import androidx.test.espresso.IdlingResource;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * An implementation of {@link IdlingResource} that determines idleness by maintaining an internal
 * counter. When the counter is 0 - it is considered to be idle, when it is non-zero it is not idle.
 * This is very similar to the way a {@link java.util.concurrent.Semaphore} behaves.
 *
 * <p>The counter may be incremented or decremented from any thread. If it reaches an illogical
 * state (like counter less than zero) it will throw an IllegalStateException.
 *
 * <p>This class can then be used to wrap up operations that while in progress should block tests
 * from accessing the UI.
 *
 * <pre>{@code
 * public interface FooServer {
 *   public Foo newFoo();
 *   public void updateFoo(Foo foo);
 * }
 *
 * public DecoratedFooServer implements FooServer {
 *   private final FooServer realFooServer;
 *   private final CountingIdlingResource fooServerIdlingResource;
 *
 *   public DecoratedFooServer(FooServer realFooServer,
 *       CountingIdlingResource fooServerIdlingResource) {
 *     this.realFooServer = checkNotNull(realFooServer);
 *     this.fooServerIdlingResource = checkNotNull(fooServerIdlingResource);
 *   }
 *
 *   public Foo newFoo() {
 *     fooServerIdlingResource.increment();
 *     try {
 *       return realFooServer.newFoo();
 *     } finally {
 *       fooServerIdlingResource.decrement();
 *     }
 *   }
 *
 *   public void updateFoo(Foo foo) {
 *     fooServerIdlingResource.increment();
 *     try {
 *       realFooServer.updateFoo(foo);
 *     } finally {
 *       fooServerIdlingResource.decrement();
 *     }
 *   }
 * }
 * }</pre>
 *
 * Then in your test setup:
 *
 * <pre>{@code
 * public void setUp() throws Exception {
 *   super.setUp();
 *   FooServer realServer = FooApplication.getFooServer();
 *   CountingIdlingResource countingResource = new CountingIdlingResource("FooServerCalls");
 *   FooApplication.setFooServer(new DecoratedFooServer(realServer, countingResource));
 *   Espresso.registerIdlingResource(countingResource);
 * }
 * }</pre>
 */
@SuppressWarnings("javadoc")
public final class CountingIdlingResource implements IdlingResource {
  private static final String TAG = "CountingIdlingResource";
  private final String resourceName;
  private final AtomicInteger counter = new AtomicInteger(0);
  private final boolean debugCounting;

  // written from main thread, read from any thread.
  private volatile ResourceCallback resourceCallback;

  // read/written from any thread - used for debugging messages.
  private volatile long becameBusyAt = 0;
  private volatile long becameIdleAt = 0;

  /**
   * Creates a CountingIdlingResource without debug tracing.
   *
   * @param resourceName the resource name this resource should report to Espresso.
   */
  public CountingIdlingResource(String resourceName) {
    this(resourceName, false);
  }

  /**
   * Creates a CountingIdlingResource.
   *
   * @param resourceName the resource name this resource should report to Espresso.
   * @param debugCounting if true increment & decrement calls will print trace information to logs.
   */
  public CountingIdlingResource(String resourceName, boolean debugCounting) {
    // TODO: replace this with Checks once we have a separate utils module
    if (TextUtils.isEmpty(resourceName)) {
      throw new IllegalArgumentException("resourceName cannot be empty or null!");
    }
    this.resourceName = resourceName;
    this.debugCounting = debugCounting;
  }

  @Override
  public String getName() {
    return resourceName;
  }

  @Override
  public boolean isIdleNow() {
    return counter.get() == 0;
  }

  @Override
  public void registerIdleTransitionCallback(ResourceCallback resourceCallback) {
    this.resourceCallback = resourceCallback;
  }

  /**
   * Increments the count of in-flight transactions to the resource being monitored.
   *
   * <p>This method can be called from any thread.
   */
  public void increment() {
    int counterVal = counter.getAndIncrement();
    if (0 == counterVal) {
      becameBusyAt = SystemClock.uptimeMillis();
    }

    if (debugCounting) {
      Log.i(TAG, "Resource: " + resourceName + " in-use-count incremented to: " + (counterVal + 1));
    }
  }

  /**
   * Decrements the count of in-flight transactions to the resource being monitored.
   *
   * <p>If this operation results in the counter falling below 0 - an exception is raised.
   *
   * @throws IllegalStateException if the counter is below 0.
   */
  public void decrement() {
    int counterVal = counter.decrementAndGet();

    if (counterVal == 0) {
      // we've gone from non-zero to zero. That means we're idle now! Tell espresso.
      if (null != resourceCallback) {
        resourceCallback.onTransitionToIdle();
      }
      becameIdleAt = SystemClock.uptimeMillis();
    }

    if (debugCounting) {
      if (counterVal == 0) {
        Log.i(
            TAG,
            "Resource: "
                + resourceName
                + " went idle! (Time spent not idle: "
                + (becameIdleAt - becameBusyAt)
                + ")");
      } else {
        Log.i(TAG, "Resource: " + resourceName + " in-use-count decremented to: " + counterVal);
      }
    }
    // TODO: replace this with Checks once we have a separate utils module
    if (counterVal <= -1) {
      throw new IllegalStateException("Counter has been corrupted! counterVal=" + counterVal);
    }
  }

  /** Prints the current state of this resource to the logcat at info level. */
  public void dumpStateToLogs() {
    StringBuilder message =
        new StringBuilder("Resource: ")
            .append(resourceName)
            .append(" inflight transaction count: ")
            .append(counter.get());
    if (0 == becameBusyAt) {
      Log.i(TAG, message.append(" and has never been busy!").toString());
    } else {
      message.append(" and was last busy at: ").append(becameBusyAt);
      if (0 == becameIdleAt) {
        Log.w(TAG, message.append(" AND NEVER WENT IDLE!").toString());
      } else {
        message.append(" and last went idle at: ").append(becameIdleAt);
        Log.i(TAG, message.toString());
      }
    }
  }
}