// copied verbatim (except for constructor access) from httpclient-4.0.3 sources

/*
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */
package org.robolectric.shadows.httpclient;

import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.ConnectionReuseStrategy;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpException;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.ProtocolException;
import org.apache.http.ProtocolVersion;
import org.apache.http.auth.AuthScheme;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.AuthState;
import org.apache.http.auth.AuthenticationException;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.MalformedChallengeException;
import org.apache.http.client.AuthenticationHandler;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.HttpRequestRetryHandler;
import org.apache.http.client.NonRepeatableRequestException;
import org.apache.http.client.RedirectException;
import org.apache.http.client.RedirectHandler;
import org.apache.http.client.RequestDirector;
import org.apache.http.client.UserTokenHandler;
import org.apache.http.client.methods.AbortableHttpRequest;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.client.params.HttpClientParams;
import org.apache.http.client.protocol.ClientContext;
import org.apache.http.client.utils.URIUtils;
import org.apache.http.conn.BasicManagedEntity;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.ClientConnectionRequest;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
import org.apache.http.conn.ManagedClientConnection;
import org.apache.http.conn.params.ConnManagerParams;
import org.apache.http.conn.routing.BasicRouteDirector;
import org.apache.http.conn.routing.HttpRoute;
import org.apache.http.conn.routing.HttpRouteDirector;
import org.apache.http.conn.routing.HttpRoutePlanner;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.entity.BufferedHttpEntity;
import org.apache.http.impl.client.EntityEnclosingRequestWrapper;
import org.apache.http.impl.client.RequestWrapper;
import org.apache.http.impl.client.RoutedRequest;
import org.apache.http.impl.client.TunnelRefusedException;
import org.apache.http.message.BasicHttpRequest;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.apache.http.params.HttpProtocolParams;
import org.apache.http.protocol.ExecutionContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpProcessor;
import org.apache.http.protocol.HttpRequestExecutor;

/**
 * Default implementation of {@link RequestDirector}.
 *
 * The following parameters can be used to customize the behavior of this
 * class:
 *
 *  * {@link org.apache.http.params.CoreProtocolPNames#PROTOCOL_VERSION}
 *  * {@link org.apache.http.params.CoreProtocolPNames#STRICT_TRANSFER_ENCODING}
 *  * {@link org.apache.http.params.CoreProtocolPNames#HTTP_ELEMENT_CHARSET}
 *  * {@link org.apache.http.params.CoreProtocolPNames#USE_EXPECT_CONTINUE}
 *  * {@link org.apache.http.params.CoreProtocolPNames#WAIT_FOR_CONTINUE}
 *  * {@link org.apache.http.params.CoreProtocolPNames#USER_AGENT}
 *  * {@link org.apache.http.params.CoreConnectionPNames#SOCKET_BUFFER_SIZE}
 *  * {@link org.apache.http.params.CoreConnectionPNames#MAX_LINE_LENGTH}
 *  * {@link org.apache.http.params.CoreConnectionPNames#MAX_HEADER_COUNT}
 *  * {@link org.apache.http.params.CoreConnectionPNames#SO_TIMEOUT}
 *  * {@link org.apache.http.params.CoreConnectionPNames#SO_LINGER}
 *  * {@link org.apache.http.params.CoreConnectionPNames#TCP_NODELAY}
 *  * {@link org.apache.http.params.CoreConnectionPNames#CONNECTION_TIMEOUT}
 *  * {@link org.apache.http.params.CoreConnectionPNames#STALE_CONNECTION_CHECK}
 *  * {@link org.apache.http.conn.params.ConnRoutePNames#FORCED_ROUTE}
 *  * {@link org.apache.http.conn.params.ConnRoutePNames#LOCAL_ADDRESS}
 *  * {@link org.apache.http.conn.params.ConnRoutePNames#DEFAULT_PROXY}
 *  * {@link org.apache.http.conn.params.ConnManagerPNames#TIMEOUT}
 *  * {@link org.apache.http.conn.params.ConnManagerPNames#MAX_CONNECTIONS_PER_ROUTE}
 *  * {@link org.apache.http.conn.params.ConnManagerPNames#MAX_TOTAL_CONNECTIONS}
 *  * {@link org.apache.http.cookie.params.CookieSpecPNames#DATE_PATTERNS}
 *  * {@link org.apache.http.cookie.params.CookieSpecPNames#SINGLE_COOKIE_HEADER}
 *  * {@link org.apache.http.auth.params.AuthPNames#CREDENTIAL_CHARSET}
 *  * {@link org.apache.http.client.params.ClientPNames#COOKIE_POLICY}
 *  * {@link org.apache.http.client.params.ClientPNames#HANDLE_AUTHENTICATION}
 *  * {@link org.apache.http.client.params.ClientPNames#HANDLE_REDIRECTS}
 *  * {@link org.apache.http.client.params.ClientPNames#MAX_REDIRECTS}
 *  * {@link org.apache.http.client.params.ClientPNames#ALLOW_CIRCULAR_REDIRECTS}
 *  * {@link org.apache.http.client.params.ClientPNames#VIRTUAL_HOST}
 *  * {@link org.apache.http.client.params.ClientPNames#DEFAULT_HOST}
 *  * {@link org.apache.http.client.params.ClientPNames#DEFAULT_HEADERS}
 *
 *
 * @since 4.0
 */
public class DefaultRequestDirector implements RequestDirector {

  private final Log log;

  /** The connection manager. */
  protected final ClientConnectionManager connManager;

  /** The route planner. */
  protected final HttpRoutePlanner routePlanner;

  /** The connection re-use strategy. */
  protected final ConnectionReuseStrategy reuseStrategy;

  /** The keep-alive duration strategy. */
  protected final ConnectionKeepAliveStrategy keepAliveStrategy;

  /** The request executor. */
  protected final HttpRequestExecutor requestExec;

  /** The HTTP protocol processor. */
  protected final HttpProcessor httpProcessor;

  /** The request retry handler. */
  protected final HttpRequestRetryHandler retryHandler;

  /** The redirect handler. */
  protected final RedirectHandler redirectHandler;

  /** The target authentication handler. */
  protected final AuthenticationHandler targetAuthHandler;

  /** The proxy authentication handler. */
  protected final AuthenticationHandler proxyAuthHandler;

  /** The user token handler. */
  protected final UserTokenHandler userTokenHandler;

  /** The HTTP parameters. */
  protected final HttpParams params;

  /** The currently allocated connection. */
  protected ManagedClientConnection managedConn;

  protected final AuthState targetAuthState;

  protected final AuthState proxyAuthState;

  private int redirectCount;

  private int maxRedirects;

  private HttpHost virtualHost;

  public DefaultRequestDirector(
      final Log log,
      final HttpRequestExecutor requestExec,
      final ClientConnectionManager conman,
      final ConnectionReuseStrategy reustrat,
      final ConnectionKeepAliveStrategy kastrat,
      final HttpRoutePlanner rouplan,
      final HttpProcessor httpProcessor,
      final HttpRequestRetryHandler retryHandler,
      final RedirectHandler redirectHandler,
      final AuthenticationHandler targetAuthHandler,
      final AuthenticationHandler proxyAuthHandler,
      final UserTokenHandler userTokenHandler,
      final HttpParams params) {

    if (log == null) {
      throw new IllegalArgumentException
        ("Log may not be null.");
    }
    if (requestExec == null) {
      throw new IllegalArgumentException
        ("Request executor may not be null.");
    }
    if (conman == null) {
      throw new IllegalArgumentException
        ("Client connection manager may not be null.");
    }
    if (reustrat == null) {
      throw new IllegalArgumentException
        ("Connection reuse strategy may not be null.");
    }
    if (kastrat == null) {
      throw new IllegalArgumentException
        ("Connection keep alive strategy may not be null.");
    }
    if (rouplan == null) {
      throw new IllegalArgumentException
        ("Route planner may not be null.");
    }
    if (httpProcessor == null) {
      throw new IllegalArgumentException
        ("HTTP protocol processor may not be null.");
    }
    if (retryHandler == null) {
      throw new IllegalArgumentException
        ("HTTP request retry handler may not be null.");
    }
    if (redirectHandler == null) {
      throw new IllegalArgumentException
        ("Redirect handler may not be null.");
    }
    if (targetAuthHandler == null) {
      throw new IllegalArgumentException
        ("Target authentication handler may not be null.");
    }
    if (proxyAuthHandler == null) {
      throw new IllegalArgumentException
        ("Proxy authentication handler may not be null.");
    }
    if (userTokenHandler == null) {
      throw new IllegalArgumentException
        ("User token handler may not be null.");
    }
    if (params == null) {
      throw new IllegalArgumentException
        ("HTTP parameters may not be null");
    }
    this.log               = log;
    this.requestExec       = requestExec;
    this.connManager       = conman;
    this.reuseStrategy     = reustrat;
    this.keepAliveStrategy = kastrat;
    this.routePlanner      = rouplan;
    this.httpProcessor     = httpProcessor;
    this.retryHandler      = retryHandler;
    this.redirectHandler   = redirectHandler;
    this.targetAuthHandler = targetAuthHandler;
    this.proxyAuthHandler  = proxyAuthHandler;
    this.userTokenHandler  = userTokenHandler;
    this.params            = params;

    this.managedConn       = null;

    this.redirectCount = 0;
    this.maxRedirects = this.params.getIntParameter(ClientPNames.MAX_REDIRECTS, 100);
    this.targetAuthState = new AuthState();
    this.proxyAuthState = new AuthState();
  } // constructor

  public DefaultRequestDirector(
      final HttpRequestExecutor requestExec,
      final ClientConnectionManager conman,
      final ConnectionReuseStrategy reustrat,
      final ConnectionKeepAliveStrategy kastrat,
      final HttpRoutePlanner rouplan,
      final HttpProcessor httpProcessor,
      final HttpRequestRetryHandler retryHandler,
      final RedirectHandler redirectHandler,
      final AuthenticationHandler targetAuthHandler,
      final AuthenticationHandler proxyAuthHandler,
      final UserTokenHandler userTokenHandler,
      final HttpParams params) {
    this(LogFactory.getLog(DefaultRequestDirector.class),
        requestExec,
        conman,
        reustrat,
        kastrat,
        rouplan,
        httpProcessor,
        retryHandler,
        redirectHandler,
        targetAuthHandler,
        proxyAuthHandler,
        userTokenHandler,
        params);

  }

  private RequestWrapper wrapRequest(
      final HttpRequest request) throws ProtocolException {
    if (request instanceof HttpEntityEnclosingRequest) {
      return new EntityEnclosingRequestWrapper(
          (HttpEntityEnclosingRequest) request);
    } else {
      return new RequestWrapper(
          request);
    }
  }

  protected void rewriteRequestURI(
      final RequestWrapper request,
      final HttpRoute route) throws ProtocolException {
    try {

      URI uri = request.getURI();
      if (route.getProxyHost() != null && !route.isTunnelled()) {
        // Make sure the request URI is absolute
        if (!uri.isAbsolute()) {
          HttpHost target = route.getTargetHost();
          uri = URIUtils.rewriteURI(uri, target);
          request.setURI(uri);
        }
      } else {
        // Make sure the request URI is relative
        if (uri.isAbsolute()) {
          uri = URIUtils.rewriteURI(uri, null);
          request.setURI(uri);
        }
      }

    } catch (URISyntaxException ex) {
      throw new ProtocolException("Invalid URI: " +
          request.getRequestLine().getUri(), ex);
    }
  }

  // non-javadoc, see interface ClientRequestDirector
  @Override public HttpResponse execute(HttpHost target, HttpRequest request,
                HttpContext context)
    throws HttpException, IOException {

    HttpRequest orig = request;
    RequestWrapper origWrapper = wrapRequest(orig);
    origWrapper.setParams(params);
    HttpRoute origRoute = determineRoute(target, origWrapper, context);

    virtualHost = (HttpHost) orig.getParams().getParameter(
        ClientPNames.VIRTUAL_HOST);

    RoutedRequest roureq = new RoutedRequest(origWrapper, origRoute);

    long timeout = ConnManagerParams.getTimeout(params);

    int execCount = 0;

    boolean reuse = false;
    boolean done = false;
    try {
      HttpResponse response = null;
      while (!done) {
        // In this loop, the RoutedRequest may be replaced by a
        // followup request and route. The request and route passed
        // in the method arguments will be replaced. The original
        // request is still available in 'orig'.

        RequestWrapper wrapper = roureq.getRequest();
        HttpRoute route = roureq.getRoute();
        response = null;

        // See if we have a user token bound to the execution context
        Object userToken = context.getAttribute(ClientContext.USER_TOKEN);

        // Allocate connection if needed
        if (managedConn == null) {
          ClientConnectionRequest connRequest = connManager.requestConnection(
              route, userToken);
          if (orig instanceof AbortableHttpRequest) {
            ((AbortableHttpRequest) orig).setConnectionRequest(connRequest);
          }

          try {
            managedConn = connRequest.getConnection(timeout, TimeUnit.MILLISECONDS);
          } catch(InterruptedException interrupted) {
            InterruptedIOException iox = new InterruptedIOException();
            iox.initCause(interrupted);
            throw iox;
          }

          if (HttpConnectionParams.isStaleCheckingEnabled(params)) {
            // validate connection
            if (managedConn.isOpen()) {
              this.log.debug("Stale connection check");
              if (managedConn.isStale()) {
                this.log.debug("Stale connection detected");
                managedConn.close();
              }
            }
          }
        }

        if (orig instanceof AbortableHttpRequest) {
          ((AbortableHttpRequest) orig).setReleaseTrigger(managedConn);
        }

        // Reopen connection if needed
        if (!managedConn.isOpen()) {
          managedConn.open(route, context, params);
        } else {
          managedConn.setSocketTimeout(HttpConnectionParams.getSoTimeout(params));
        }

        try {
          establishRoute(route, context);
        } catch (TunnelRefusedException ex) {
          if (this.log.isDebugEnabled()) {
            this.log.debug(ex.getMessage());
          }
          response = ex.getResponse();
          break;
        }

        // Reset headers on the request wrapper
        wrapper.resetHeaders();

        // Re-write request URI if needed
        rewriteRequestURI(wrapper, route);

        // Use virtual host if set
        target = virtualHost;

        if (target == null) {
          target = route.getTargetHost();
        }

        HttpHost proxy = route.getProxyHost();

        // Populate the execution context
        context.setAttribute(ExecutionContext.HTTP_TARGET_HOST,
            target);
        context.setAttribute(ExecutionContext.HTTP_PROXY_HOST,
            proxy);
        context.setAttribute(ExecutionContext.HTTP_CONNECTION,
            managedConn);
        context.setAttribute(ClientContext.TARGET_AUTH_STATE,
            targetAuthState);
        context.setAttribute(ClientContext.PROXY_AUTH_STATE,
            proxyAuthState);

        // Run request protocol interceptors
        requestExec.preProcess(wrapper, httpProcessor, context);

        boolean retrying = true;
        Exception retryReason = null;
        while (retrying) {
          // Increment total exec count (with redirects)
          execCount++;
          // Increment exec count for this particular request
          wrapper.incrementExecCount();
          if (!wrapper.isRepeatable()) {
            this.log.debug("Cannot retry non-repeatable request");
            if (retryReason != null) {
              throw new NonRepeatableRequestException("Cannot retry request " +
                "with a non-repeatable request entity.  The cause lists the " +
                "reason the original request failed: " + retryReason);
            } else {
              throw new NonRepeatableRequestException("Cannot retry request " +
                  "with a non-repeatable request entity.");
            }
          }

          try {
            if (this.log.isDebugEnabled()) {
              this.log.debug("Attempt " + execCount + " to execute request");
            }
            response = requestExec.execute(wrapper, managedConn, context);
            retrying = false;

          } catch (IOException ex) {
            this.log.debug("Closing the connection.");
            managedConn.close();
            if (retryHandler.retryRequest(ex, wrapper.getExecCount(), context)) {
              if (this.log.isInfoEnabled()) {
                this.log.info("I/O exception ("+ ex.getClass().getName() +
                    ") caught when processing request: "
                    + ex.getMessage());
              }
              if (this.log.isDebugEnabled()) {
                this.log.debug(ex.getMessage(), ex);
              }
              this.log.info("Retrying request");
              retryReason = ex;
            } else {
              throw ex;
            }

            // If we have a direct route to the target host
            // just re-open connection and re-try the request
            if (!route.isTunnelled()) {
              this.log.debug("Reopening the direct connection.");
              managedConn.open(route, context, params);
            } else {
              // otherwise give up
              this.log.debug("Proxied connection. Need to start over.");
              retrying = false;
            }

          }

        }

        if (response == null) {
          // Need to start over
          continue;
        }

        // Run response protocol interceptors
        response.setParams(params);
        requestExec.postProcess(response, httpProcessor, context);

        // The connection is in or can be brought to a re-usable state.
        reuse = reuseStrategy.keepAlive(response, context);
        if (reuse) {
          // Set the idle duration of this connection
          long duration = keepAliveStrategy.getKeepAliveDuration(response, context);
          managedConn.setIdleDuration(duration, TimeUnit.MILLISECONDS);

          if (this.log.isDebugEnabled()) {
            if (duration >= 0) {
              this.log.debug("Connection can be kept alive for " + duration + " ms");
            } else {
              this.log.debug("Connection can be kept alive indefinitely");
            }
          }
        }

        RoutedRequest followup = handleResponse(roureq, response, context);
        if (followup == null) {
          done = true;
        } else {
          if (reuse) {
            // Make sure the response body is fully consumed, if present
            HttpEntity entity = response.getEntity();
            if (entity != null) {
              entity.consumeContent();
            }
            // entity consumed above is not an auto-release entity,
            // need to mark the connection re-usable explicitly
            managedConn.markReusable();
          } else {
            managedConn.close();
          }
          // check if we can use the same connection for the followup
          if (!followup.getRoute().equals(roureq.getRoute())) {
            releaseConnection();
          }
          roureq = followup;
        }

        if (managedConn != null && userToken == null) {
          userToken = userTokenHandler.getUserToken(context);
          context.setAttribute(ClientContext.USER_TOKEN, userToken);
          if (userToken != null) {
            managedConn.setState(userToken);
          }
        }

      } // while not done

      // check for entity, release connection if possible
      if ((response == null) || (response.getEntity() == null) ||
        !response.getEntity().isStreaming()) {
        // connection not needed and (assumed to be) in re-usable state
        if (reuse)
          managedConn.markReusable();
        releaseConnection();
      } else {
        // install an auto-release entity
        HttpEntity entity = response.getEntity();
        entity = new BasicManagedEntity(entity, managedConn, reuse);
        response.setEntity(entity);
      }

      return response;

    } catch (HttpException | RuntimeException | IOException ex) {
      abortConnection();
      throw ex;
    }
  } // execute

  /**
   * Returns the connection back to the connection manager
   * and prepares for retrieving a new connection during
   * the next request.
   */
  protected void releaseConnection() {
    // Release the connection through the ManagedConnection instead of the
    // ConnectionManager directly.  This lets the connection control how
    // it is released.
    try {
      managedConn.releaseConnection();
    } catch(IOException ignored) {
      this.log.debug("IOException releasing connection", ignored);
    }
    managedConn = null;
  }

  /**
   * Determines the route for a request.
   * Called by {@link #execute}
   * to determine the route for either the original or a followup request.
   *
   * @param target    the target host for the request.
   *                  Implementations may accept {@code null}
   *                  if they can still determine a route, for example
   *                  to a default target or by inspecting the request.
   * @param request   the request to execute
   * @param context   the context to use for the execution,
   *                  never {@code null}
   *
   * @return  the route the request should take
   *
   * @throws HttpException    in case of a problem
   */
  protected HttpRoute determineRoute(HttpHost    target,
                       HttpRequest request,
                       HttpContext context)
    throws HttpException {

    if (target == null) {
      target = (HttpHost) request.getParams().getParameter(
        ClientPNames.DEFAULT_HOST);
    }
    if (target == null) {
      throw new IllegalStateException
        ("Target host must not be null, or set in parameters.");
    }

    return this.routePlanner.determineRoute(target, request, context);
  }

  /**
   * Establishes the target route.
   *
   * @param route     the route to establish
   * @param context   the context for the request execution
   *
   * @throws HttpException    in case of a problem
   * @throws IOException      in case of an IO problem
   */
  protected void establishRoute(HttpRoute route, HttpContext context)
    throws HttpException, IOException {

    HttpRouteDirector rowdy = new BasicRouteDirector();
    int step;
    do {
      HttpRoute fact = managedConn.getRoute();
      step = rowdy.nextStep(route, fact);

      switch (step) {

      case HttpRouteDirector.CONNECT_TARGET:
      case HttpRouteDirector.CONNECT_PROXY:
        managedConn.open(route, context, this.params);
        break;

      case HttpRouteDirector.TUNNEL_TARGET: {
        boolean secure = createTunnelToTarget(route, context);
        this.log.debug("Tunnel to target created.");
        managedConn.tunnelTarget(secure, this.params);
      }   break;

      case HttpRouteDirector.TUNNEL_PROXY: {
        // The most simple example for this case is a proxy chain
        // of two proxies, where P1 must be tunnelled to P2.
        // route: Source -> P1 -> P2 -> Target (3 hops)
        // fact:  Source -> P1 -> Target       (2 hops)
        final int hop = fact.getHopCount()-1; // the hop to establish
        boolean secure = createTunnelToProxy(route, hop, context);
        this.log.debug("Tunnel to proxy created.");
        managedConn.tunnelProxy(route.getHopTarget(hop),
                    secure, this.params);
      }   break;

      case HttpRouteDirector.LAYER_PROTOCOL:
        managedConn.layerProtocol(context, this.params);
        break;

      case HttpRouteDirector.UNREACHABLE:
        throw new IllegalStateException
          ("Unable to establish route." +
           "\nplanned = " + route +
           "\ncurrent = " + fact);

      case HttpRouteDirector.COMPLETE:
        // do nothing
        break;

      default:
        throw new IllegalStateException
          ("Unknown step indicator "+step+" from RouteDirector.");
      } // switch

    } while (step > HttpRouteDirector.COMPLETE);

  } // establishConnection

  /**
   * Creates a tunnel to the target server.
   * The connection must be established to the (last) proxy.
   * A CONNECT request for tunnelling through the proxy will
   * be created and sent, the response received and checked.
   * This method does <i>not</i> update the connection with
   * information about the tunnel, that is left to the caller.
   *
   * @param route     the route to establish
   * @param context   the context for request execution
   *
   * @return  {@code true} if the tunnelled route is secure,
   *          {@code false} otherwise.
   *          The implementation here always returns {@code false},
   *          but derived classes may override.
   *
   * @throws HttpException    in case of a problem
   * @throws IOException      in case of an IO problem
   */
  protected boolean createTunnelToTarget(HttpRoute route,
                       HttpContext context)
    throws HttpException, IOException {

    HttpHost proxy = route.getProxyHost();
    HttpHost target = route.getTargetHost();
    HttpResponse response = null;

    boolean done = false;
    while (!done) {

      done = true;

      if (!this.managedConn.isOpen()) {
        this.managedConn.open(route, context, this.params);
      }

      HttpRequest connect = createConnectRequest(route, context);
      connect.setParams(this.params);

      // Populate the execution context
      context.setAttribute(ExecutionContext.HTTP_TARGET_HOST,
          target);
      context.setAttribute(ExecutionContext.HTTP_PROXY_HOST,
          proxy);
      context.setAttribute(ExecutionContext.HTTP_CONNECTION,
          managedConn);
      context.setAttribute(ClientContext.TARGET_AUTH_STATE,
          targetAuthState);
      context.setAttribute(ClientContext.PROXY_AUTH_STATE,
          proxyAuthState);
      context.setAttribute(ExecutionContext.HTTP_REQUEST,
          connect);

      this.requestExec.preProcess(connect, this.httpProcessor, context);

      response = this.requestExec.execute(connect, this.managedConn, context);

      response.setParams(this.params);
      this.requestExec.postProcess(response, this.httpProcessor, context);

      int status = response.getStatusLine().getStatusCode();
      if (status < 200) {
        throw new HttpException("Unexpected response to CONNECT request: " +
            response.getStatusLine());
      }

      CredentialsProvider credsProvider = (CredentialsProvider)
        context.getAttribute(ClientContext.CREDS_PROVIDER);

      if (credsProvider != null && HttpClientParams.isAuthenticating(params)) {
        if (this.proxyAuthHandler.isAuthenticationRequested(response, context)) {

          this.log.debug("Proxy requested authentication");
          Map<String, Header> challenges = this.proxyAuthHandler.getChallenges(
              response, context);
          try {
            processChallenges(
                challenges, this.proxyAuthState, this.proxyAuthHandler,
                response, context);
          } catch (AuthenticationException ex) {
            if (this.log.isWarnEnabled()) {
              this.log.warn("Authentication error: " +  ex.getMessage());
              break;
            }
          }
          updateAuthState(this.proxyAuthState, proxy, credsProvider);

          if (this.proxyAuthState.getCredentials() != null) {
            done = false;

            // Retry request
            if (this.reuseStrategy.keepAlive(response, context)) {
              this.log.debug("Connection kept alive");
              // Consume response content
              HttpEntity entity = response.getEntity();
              if (entity != null) {
                entity.consumeContent();
              }
            } else {
              this.managedConn.close();
            }

          }

        } else {
          // Reset proxy auth scope
          this.proxyAuthState.setAuthScope(null);
        }
      }
    }

    int status = response.getStatusLine().getStatusCode(); // can't be null

    if (status > 299) {

      // Buffer response content
      HttpEntity entity = response.getEntity();
      if (entity != null) {
        response.setEntity(new BufferedHttpEntity(entity));
      }

      this.managedConn.close();
      throw new TunnelRefusedException("CONNECT refused by proxy: " +
          response.getStatusLine(), response);
    }

    this.managedConn.markReusable();

    // How to decide on security of the tunnelled connection?
    // The socket factory knows only about the segment to the proxy.
    // Even if that is secure, the hop to the target may be insecure.
    // Leave it to derived classes, consider insecure by default here.
    return false;

  } // createTunnelToTarget

  /**
   * Creates a tunnel to an intermediate proxy.
   * This method is <i>not</i> implemented in this class.
   * It just throws an exception here.
   *
   * @param route     the route to establish
   * @param hop       the hop in the route to establish now.
   *                  {@code route.getHopTarget(hop)}
   *                  will return the proxy to tunnel to.
   * @param context   the context for request execution
   *
   * @return  {@code true} if the partially tunnelled connection
   *          is secure, {@code false} otherwise.
   *
   * @throws HttpException    in case of a problem
   * @throws IOException      in case of an IO problem
   */
  protected boolean createTunnelToProxy(HttpRoute route, int hop,
                      HttpContext context)
    throws HttpException, IOException {

    // Have a look at createTunnelToTarget and replicate the parts
    // you need in a custom derived class. If your proxies don't require
    // authentication, it is not too hard. But for the stock version of
    // HttpClient, we cannot make such simplifying assumptions and would
    // have to include proxy authentication code. The HttpComponents team
    // is currently not in a position to support rarely used code of this
    // complexity. Feel free to submit patches that refactor the code in
    // createTunnelToTarget to facilitate re-use for proxy tunnelling.

    throw new UnsupportedOperationException
      ("Proxy chains are not supported.");
  }

  /**
   * Creates the CONNECT request for tunnelling.
   * Called by {@link #createTunnelToTarget createTunnelToTarget}.
   *
   * @param route     the route to establish
   * @param context   the context for request execution
   *
   * @return  the CONNECT request for tunnelling
   */
  protected HttpRequest createConnectRequest(HttpRoute route,
                         HttpContext context) {
    // see RFC 2817, section 5.2 and
    // INTERNET-DRAFT: Tunneling TCP based protocols through
    // Web proxy servers

    HttpHost target = route.getTargetHost();

    String host = target.getHostName();
    int port = target.getPort();
    if (port < 0) {
      Scheme scheme = connManager.getSchemeRegistry().
        getScheme(target.getSchemeName());
      port = scheme.getDefaultPort();
    }

    StringBuilder buffer = new StringBuilder(host.length() + 6);
    buffer.append(host);
    buffer.append(':');
    buffer.append(Integer.toString(port));

    String authority = buffer.toString();
    ProtocolVersion ver = HttpProtocolParams.getVersion(params);
    HttpRequest req = new BasicHttpRequest
      ("CONNECT", authority, ver);

    return req;
  }

  /**
   * Analyzes a response to check need for a followup.
   *
   * @param roureq    the request and route.
   * @param response  the response to analayze
   * @param context   the context used for the current request execution
   *
   * @return  the followup request and route if there is a followup, or
   *          {@code null} if the response should be returned as is
   *
   * @throws HttpException    in case of a problem
   * @throws IOException      in case of an IO problem
   */
  protected RoutedRequest handleResponse(RoutedRequest roureq,
                       HttpResponse response,
                       HttpContext context)
    throws HttpException, IOException {

    HttpRoute route = roureq.getRoute();
    RequestWrapper request = roureq.getRequest();

    HttpParams params = request.getParams();
    if (HttpClientParams.isRedirecting(params) &&
        this.redirectHandler.isRedirectRequested(response, context)) {

      if (redirectCount >= maxRedirects) {
        throw new RedirectException("Maximum redirects ("
            + maxRedirects + ") exceeded");
      }
      redirectCount++;

      // Virtual host cannot be used any longer
      virtualHost = null;

      URI uri = this.redirectHandler.getLocationURI(response, context);

      HttpHost newTarget = new HttpHost(
          uri.getHost(),
          uri.getPort(),
          uri.getScheme());

      // Unset auth scope
      targetAuthState.setAuthScope(null);
      proxyAuthState.setAuthScope(null);

      // Invalidate auth states if redirecting to another host
      if (!route.getTargetHost().equals(newTarget)) {
        targetAuthState.invalidate();
        AuthScheme authScheme = proxyAuthState.getAuthScheme();
        if (authScheme != null && authScheme.isConnectionBased()) {
          proxyAuthState.invalidate();
        }
      }

      HttpRedirect redirect = new HttpRedirect(request.getMethod(), uri);
      HttpRequest orig = request.getOriginal();
      redirect.setHeaders(orig.getAllHeaders());

      RequestWrapper wrapper = new RequestWrapper(redirect);
      wrapper.setParams(params);

      HttpRoute newRoute = determineRoute(newTarget, wrapper, context);
      RoutedRequest newRequest = new RoutedRequest(wrapper, newRoute);

      if (this.log.isDebugEnabled()) {
        this.log.debug("Redirecting to '" + uri + "' via " + newRoute);
      }

      return newRequest;
    }

    CredentialsProvider credsProvider = (CredentialsProvider)
      context.getAttribute(ClientContext.CREDS_PROVIDER);

    if (credsProvider != null && HttpClientParams.isAuthenticating(params)) {

      if (this.targetAuthHandler.isAuthenticationRequested(response, context)) {

        HttpHost target = (HttpHost)
          context.getAttribute(ExecutionContext.HTTP_TARGET_HOST);
        if (target == null) {
          target = route.getTargetHost();
        }

        this.log.debug("Target requested authentication");
        Map<String, Header> challenges = this.targetAuthHandler.getChallenges(
            response, context);
        try {
          processChallenges(challenges,
              this.targetAuthState, this.targetAuthHandler,
              response, context);
        } catch (AuthenticationException ex) {
          if (this.log.isWarnEnabled()) {
            this.log.warn("Authentication error: " +  ex.getMessage());
            return null;
          }
        }
        updateAuthState(this.targetAuthState, target, credsProvider);

        if (this.targetAuthState.getCredentials() != null) {
          // Re-try the same request via the same route
          return roureq;
        } else {
          return null;
        }
      } else {
        // Reset target auth scope
        this.targetAuthState.setAuthScope(null);
      }

      if (this.proxyAuthHandler.isAuthenticationRequested(response, context)) {

        HttpHost proxy = route.getProxyHost();

        this.log.debug("Proxy requested authentication");
        Map<String, Header> challenges = this.proxyAuthHandler.getChallenges(
            response, context);
        try {
          processChallenges(challenges,
              this.proxyAuthState, this.proxyAuthHandler,
              response, context);
        } catch (AuthenticationException ex) {
          if (this.log.isWarnEnabled()) {
            this.log.warn("Authentication error: " +  ex.getMessage());
            return null;
          }
        }
        updateAuthState(this.proxyAuthState, proxy, credsProvider);

        if (this.proxyAuthState.getCredentials() != null) {
          // Re-try the same request via the same route
          return roureq;
        } else {
          return null;
        }
      } else {
        // Reset proxy auth scope
        this.proxyAuthState.setAuthScope(null);
      }
    }
    return null;
  } // handleResponse

  /**
   * Shuts down the connection.
   * This method is called from a {@code catch} block in
   * {@link #execute execute} during exception handling.
   */
  private void abortConnection() {
    ManagedClientConnection mcc = managedConn;
    if (mcc != null) {
      // we got here as the result of an exception
      // no response will be returned, release the connection
      managedConn = null;
      try {
        mcc.abortConnection();
      } catch (IOException ex) {
        if (this.log.isDebugEnabled()) {
          this.log.debug(ex.getMessage(), ex);
        }
      }
      // ensure the connection manager properly releases this connection
      try {
        mcc.releaseConnection();
      } catch(IOException ignored) {
        this.log.debug("Error releasing connection", ignored);
      }
    }
  } // abortConnection

  private void processChallenges(
      final Map<String, Header> challenges,
      final AuthState authState,
      final AuthenticationHandler authHandler,
      final HttpResponse response,
      final HttpContext context)
        throws MalformedChallengeException, AuthenticationException {

    AuthScheme authScheme = authState.getAuthScheme();
    if (authScheme == null) {
      // Authentication not attempted before
      authScheme = authHandler.selectScheme(challenges, response, context);
      authState.setAuthScheme(authScheme);
    }
    String id = authScheme.getSchemeName();

    Header challenge = challenges.get(id.toLowerCase(Locale.ENGLISH));
    if (challenge == null) {
      throw new AuthenticationException(id +
        " authorization challenge expected, but not found");
    }
    authScheme.processChallenge(challenge);
    this.log.debug("Authorization challenge processed");
  }

  private void updateAuthState(
      final AuthState authState,
      final HttpHost host,
      final CredentialsProvider credsProvider) {

    if (!authState.isValid()) {
      return;
    }

    String hostname = host.getHostName();
    int port = host.getPort();
    if (port < 0) {
      Scheme scheme = connManager.getSchemeRegistry().getScheme(host);
      port = scheme.getDefaultPort();
    }

    AuthScheme authScheme = authState.getAuthScheme();
    AuthScope authScope = new AuthScope(
        hostname,
        port,
        authScheme.getRealm(),
        authScheme.getSchemeName());

    if (this.log.isDebugEnabled()) {
      this.log.debug("Authentication scope: " + authScope);
    }
    Credentials creds = authState.getCredentials();
    if (creds == null) {
      creds = credsProvider.getCredentials(authScope);
      if (this.log.isDebugEnabled()) {
        if (creds != null) {
          this.log.debug("Found credentials");
        } else {
          this.log.debug("Credentials not found");
        }
      }
    } else {
      if (authScheme.isComplete()) {
        this.log.debug("Authentication failed");
        creds = null;
      }
    }
    authState.setAuthScope(authScope);
    authState.setCredentials(creds);
  }

} // class DefaultClientRequestDirector


package org.robolectric.internal.bytecode;

import static org.objectweb.asm.Type.ARRAY;
import static org.objectweb.asm.Type.OBJECT;

import java.util.HashMap;
import java.util.Map;
import org.objectweb.asm.Type;

class TypeMapper {
  private final Map<String, String> classesToRemap;

  public TypeMapper(Map<String, String> classNameToClassNameMap) {
    classesToRemap = convertToSlashes(classNameToClassNameMap);
  }

  private static Map<String, String> convertToSlashes(Map<String, String> map) {
    HashMap<String, String> newMap = new HashMap<>();
    for (Map.Entry<String, String> entry : map.entrySet()) {
      String key = internalize(entry.getKey());
      String value = internalize(entry.getValue());
      newMap.put(key, value);
      newMap.put("L" + key + ";", "L" + value + ";"); // also the param reference form
    }
    return newMap;
  }

  private static String internalize(String className) {
    return className.replace('.', '/');
  }

  // remap android/Foo to android/Bar
  String mappedTypeName(String internalName) {
    String remappedInternalName = classesToRemap.get(internalName);
    if (remappedInternalName != null) {
      return remappedInternalName;
    } else {
      return internalName;
    }
  }

  Type mappedType(Type type) {
    String internalName = type.getInternalName();
    String remappedInternalName = classesToRemap.get(internalName);
    if (remappedInternalName != null) {
      return Type.getObjectType(remappedInternalName);
    } else {
      return type;
    }
  }

  String remapParams(String desc) {
    StringBuilder buf = new StringBuilder();
    buf.append("(");
    for (Type type : Type.getArgumentTypes(desc)) {
      buf.append(remapParamType(type));
    }
    buf.append(")");
    buf.append(remapParamType(Type.getReturnType(desc)));
    return buf.toString();
  }

  // remap Landroid/Foo; to Landroid/Bar;
  String remapParamType(String desc) {
    return remapParamType(Type.getType(desc));
  }

  private String remapParamType(Type type) {
    String remappedName;
    String internalName;

    switch (type.getSort()) {
      case ARRAY:
        internalName = type.getInternalName();
        int count = 0;
        while (internalName.charAt(count) == '[') count++;

        remappedName = remapParamType(internalName.substring(count));
        if (remappedName != null) {
          return Type.getObjectType(internalName.substring(0, count) + remappedName).getDescriptor();
        }
        break;

      case OBJECT:
        type = mappedType(type);
        break;

      default:
        break;
    }
    return type.getDescriptor();
  }
}


package org.robolectric.internal.bytecode;

import java.util.List;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.ClassNode;

/**
 * ClassWriter implementation that verifies classes by comparing type information obtained
 * from loading the classes as resources. This was taken from the ASM ClassWriter unit tests.
 */
public class InstrumentingClassWriter extends ClassWriter {

  private final ClassNodeProvider classNodeProvider;

  /**
   * Preserve stack map frames for V51 and newer bytecode. This fixes class verification errors for
   * JDK7 and JDK8. The option to disable bytecode verification was removed in JDK8.
   *
   * <p>Don't bother for V50 and earlier bytecode, because it doesn't contain stack map frames, and
   * also because ASM's stack map frame handling doesn't support the JSR and RET instructions
   * present in legacy bytecode.
   */
  public InstrumentingClassWriter(ClassNodeProvider classNodeProvider, ClassNode classNode) {
    super(classNode.version >= 51 ? ClassWriter.COMPUTE_FRAMES : ClassWriter.COMPUTE_MAXS);
    this.classNodeProvider = classNodeProvider;
  }

  /**
   * Returns the common super type of the two given types without actually loading
   * the classes in the ClassLoader.
   */
  @Override
  protected String getCommonSuperClass(final String type1, final String type2) {
    try {
      ClassNode info1 = typeInfo(type1);
      ClassNode info2 = typeInfo(type2);
      if ((info1.access & Opcodes.ACC_INTERFACE) != 0) {
        if (typeImplements(type2, info2, type1)) {
          return type1;
        }
        if ((info2.access & Opcodes.ACC_INTERFACE) != 0) {
          if (typeImplements(type1, info1, type2)) {
            return type2;
          }
        }
        return "java/lang/Object";
      }
      if ((info2.access & Opcodes.ACC_INTERFACE) != 0) {
        if (typeImplements(type1, info1, type2)) {
          return type2;
        } else {
          return "java/lang/Object";
        }
      }
      String b1 = typeAncestors(type1, info1);
      String b2 = typeAncestors(type2, info2);
      String result = "java/lang/Object";
      int end1 = b1.length();
      int end2 = b2.length();
      while (true) {
        int start1 = b1.lastIndexOf(';', end1 - 1);
        int start2 = b2.lastIndexOf(';', end2 - 1);
        if (start1 != -1 && start2 != -1
            && end1 - start1 == end2 - start2) {
          String p1 = b1.substring(start1 + 1, end1);
          String p2 = b2.substring(start2 + 1, end2);
          if (p1.equals(p2)) {
            result = p1;
            end1 = start1;
            end2 = start2;
          } else {
            return result;
          }
        } else {
          return result;
        }
      }
    } catch (ClassNotFoundException e) {
      return "java/lang/Object"; // Handle classes that may be obfuscated
    }
  }

  private String typeAncestors(String type, ClassNode info) throws ClassNotFoundException {
    StringBuilder b = new StringBuilder();
    while (!"java/lang/Object".equals(type)) {
      b.append(';').append(type);
      type = info.superName;
      info = typeInfo(type);
    }
    return b.toString();
  }

  private boolean typeImplements(String type, ClassNode info, String itf)
      throws ClassNotFoundException {
    while (!"java/lang/Object".equals(type)) {
      List<String> itfs = info.interfaces;
      for (String itf2 : itfs) {
        if (itf2.equals(itf)) {
          return true;
        }
      }
      for (String itf1 : itfs) {
        if (typeImplements(itf1, typeInfo(itf1), itf)) {
          return true;
        }
      }
      type = info.superName;
      info = typeInfo(type);
    }
    return false;
  }

  private ClassNode typeInfo(final String type) throws ClassNotFoundException {
    return classNodeProvider.getClassNode(type);
  }
}


package org.robolectric.errorprone.bugpatterns;

import static com.google.errorprone.BugPattern.SeverityLevel.SUGGESTION;
import static com.google.errorprone.matchers.Description.NO_MATCH;
import static com.google.errorprone.matchers.Matchers.argumentCount;
import static com.google.errorprone.matchers.Matchers.staticMethod;
import static com.google.errorprone.util.ASTHelpers.getSymbol;
import static com.google.errorprone.util.ASTHelpers.hasAnnotation;
import static org.robolectric.errorprone.bugpatterns.Helpers.isCastableTo;
import static org.robolectric.errorprone.bugpatterns.Helpers.isInShadowClass;

import com.google.auto.service.AutoService;
import com.google.errorprone.BugPattern;
import com.google.errorprone.BugPattern.LinkType;
import com.google.errorprone.BugPattern.ProvidesFix;
import com.google.errorprone.BugPattern.StandardTags;
import com.google.errorprone.VisitorState;
import com.google.errorprone.bugpatterns.BugChecker;
import com.google.errorprone.bugpatterns.BugChecker.ClassTreeMatcher;
import com.google.errorprone.fixes.Fix;
import com.google.errorprone.fixes.SuggestedFix;
import com.google.errorprone.fixes.SuggestedFix.Builder;
import com.google.errorprone.fixes.SuggestedFixes;
import com.google.errorprone.matchers.Description;
import com.google.errorprone.matchers.Matcher;
import com.google.errorprone.matchers.Matchers;
import com.google.errorprone.util.ASTHelpers;
import com.sun.source.tree.AssignmentTree;
import com.sun.source.tree.ClassTree;
import com.sun.source.tree.CompilationUnitTree;
import com.sun.source.tree.ExpressionStatementTree;
import com.sun.source.tree.ExpressionTree;
import com.sun.source.tree.IdentifierTree;
import com.sun.source.tree.MemberSelectTree;
import com.sun.source.tree.MethodInvocationTree;
import com.sun.source.tree.MethodTree;
import com.sun.source.tree.Tree;
import com.sun.source.tree.Tree.Kind;
import com.sun.source.tree.VariableTree;
import com.sun.source.util.TreePath;
import com.sun.source.util.TreePathScanner;
import com.sun.source.util.TreeScanner;
import com.sun.tools.javac.code.Attribute;
import com.sun.tools.javac.code.Attribute.Compound;
import com.sun.tools.javac.code.Symbol;
import com.sun.tools.javac.code.Symbol.ClassSymbol;
import com.sun.tools.javac.code.Symbol.MethodSymbol;
import com.sun.tools.javac.code.Type;
import com.sun.tools.javac.code.Type.ClassType;
import com.sun.tools.javac.code.Type.UnknownType;
import com.sun.tools.javac.code.Types;
import com.sun.tools.javac.tree.JCTree;
import com.sun.tools.javac.tree.JCTree.JCAssign;
import com.sun.tools.javac.tree.JCTree.JCCompilationUnit;
import com.sun.tools.javac.tree.JCTree.JCExpression;
import com.sun.tools.javac.tree.JCTree.JCFieldAccess;
import com.sun.tools.javac.tree.JCTree.JCIdent;
import com.sun.tools.javac.tree.JCTree.JCMethodDecl;
import com.sun.tools.javac.tree.JCTree.JCMethodInvocation;
import com.sun.tools.javac.tree.JCTree.JCNewClass;
import com.sun.tools.javac.tree.JCTree.JCTypeCast;
import com.sun.tools.javac.tree.JCTree.JCVariableDecl;
import com.sun.tools.javac.tree.TreeMaker;
import com.sun.tools.javac.util.JCDiagnostic.DiagnosticPosition;
import com.sun.tools.javac.util.Name;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import javax.lang.model.element.AnnotationValueVisitor;
import javax.lang.model.element.ElementKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.SimpleAnnotationValueVisitor6;
import org.robolectric.annotation.HiddenApi;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.errorprone.bugpatterns.Helpers.AnnotatedMethodMatcher;

/** @author christianw@google.com (Christian Williams) */
@AutoService(BugChecker.class)
@BugPattern(
    name = "ShadowUsageCheck",
    summary = "Robolectric shadows shouldn't be stored to variables or fields.",
    severity = SUGGESTION,
    documentSuppression = false,
    tags = StandardTags.REFACTORING,
    link = "http://robolectric.org/migrating/#improper-use-of-shadows",
    linkType = LinkType.CUSTOM,
    providesFix = ProvidesFix.REQUIRES_HUMAN_ATTENTION)
public final class ShadowUsageCheck extends BugChecker implements ClassTreeMatcher {

  /** Matches when the shadowOf method is used to obtain a shadow from an instrumented instance. */
  private static final Matcher<MethodInvocationTree> shadowStaticMatcher =
      Matchers.allOf(staticMethod(), new AnnotatedMethodMatcher(Implementation.class));

  /** Matches when the shadowOf method is used to obtain a shadow from an instrumented instance. */
  private static final Matcher<MethodInvocationTree> shadowOfMatcher =
      Matchers.allOf(
          staticMethod()
              .onClass(isCastableTo("org.robolectric.internal.ShadowProvider"))
              .named("shadowOf"),
          argumentCount(1));

  @Override
  public Description matchClass(ClassTree tree, VisitorState state) {
    if (isInShadowClass(state.getPath(), state)) {
      return NO_MATCH;
    }

    final ShadowInliner shadowInliner =
        new ShadowInliner((JCCompilationUnit) state.getPath().getCompilationUnit());
    shadowInliner.scan(tree, state);

    Fix fix = shadowInliner.possibleFixes.getFix();
    return fix.isEmpty() ? NO_MATCH : describeMatch(tree, fix);
  }

  static class ShadowInliner extends TreeScanner<Void, VisitorState> {
    private final PossibleFixes possibleFixes;

    private final Set<String> knownFields = new HashSet<>();
    private final Set<String> knownLocalVars = new HashSet<>();
    private final Map<Symbol, String> varRemapping = new HashMap<>();

    private boolean inShadowClass;

    ShadowInliner(JCCompilationUnit compilationUnit) {
      this(new PossibleFixes(SuggestedFix.builder(), compilationUnit));
    }

    ShadowInliner(PossibleFixes possibleFixes) {
      this.possibleFixes = possibleFixes;
    }

    private void matchedShadowOf(MethodInvocationTree shadowOfCall, VisitorState state) {
      ExpressionTree shadowOfArg = shadowOfCall.getArguments().get(0);
      Type shadowOfArgType = getExpressionType(shadowOfArg);

      Tree parent = state.getPath().getParentPath().getLeaf();
      CompilationUnitTree compilationUnit = state.getPath().getCompilationUnit();

      // pointless (ShadowX) shadowOf(x)? drop it.
      if (parent.getKind() == Kind.TYPE_CAST) {
        parent = removeCastIfUnnecessary((JCTypeCast) parent, state);
      }

      switch (parent.getKind()) {
        case VARIABLE: // ShadowType shadowType = shadowOf(type);
          {
            // shadow is being assigned to a variable; don't do that!
            JCVariableDecl variableDecl = (JCVariableDecl) parent;
            String oldVarName = variableDecl.getName().toString();

            // since it's being declared here, no danger of a collision on this var name...
            knownLocalVars.remove(oldVarName);

            String newVarName = pickNewName(shadowOfArg, oldVarName, this);
            varRemapping.put(getSymbol(variableDecl), newVarName);

            // ... but be careful not to collide with it later.
            knownLocalVars.add(newVarName);

            // replace shadow variable declaration with shadowed type and name
            if (!newVarName.equals(shadowOfArg.toString())) {
              Type shadowedType = getUpperBound(shadowOfArgType, state);
              String shadowedTypeName =
                  SuggestedFixes.prettyType(state, possibleFixes.fixBuilder, shadowedType);
              String newAssignment =
                  shadowedTypeName + " " + newVarName + " = " + shadowOfArg + ";";

              // avoid overlapping replacements:
              if (shadowOfArg instanceof JCMethodInvocation) {
                JCExpression jcExpression = ((JCMethodInvocation) shadowOfArg).meth;
                if (jcExpression instanceof JCFieldAccess) {
                  possibleFixes.removeFixFor(((JCFieldAccess) jcExpression).selected);
                }
              }

              possibleFixes.fixByReplacing(parent, newAssignment);
            } else {
              possibleFixes.fixByDeleting(parent);
            }

            // replace shadow variable reference with `nonShadowInstance` or
            // `shadowOf(nonShadowInstance)` as appropriate.
            new TreePathScanner<Void, Void>() {
              @Override
              public Void visitIdentifier(IdentifierTree identifierTreeX, Void aVoid) {
                JCIdent identifierTree = (JCIdent) identifierTreeX;

                Symbol symbol = getSymbol(identifierTree);
                if (variableDecl.sym.equals(symbol) && !isLeftSideOfAssignment(identifierTree)) {
                  TreePath idPath = TreePath.getPath(compilationUnit, identifierTree);
                  Tree parent = idPath.getParentPath().getLeaf();
                  boolean callDirectlyOnFramework = shouldCallDirectlyOnFramework(idPath);

                  JCTree replaceNode;
                  if (parent instanceof JCFieldAccess && !callDirectlyOnFramework) {
                    JCFieldAccess fieldAccess = (JCFieldAccess) parent;
                    JCMethodInvocation newShadowOfCall =
                        createSyntheticShadowAccess(shadowOfCall, newVarName, symbol, state);

                    replaceFieldSelected(fieldAccess, newShadowOfCall, state);
                    replaceNode = newShadowOfCall;
                  } else {
                    identifierTree.name = state.getName(newVarName);
                    identifierTree.sym.name = state.getName(newVarName);
                    replaceNode = identifierTree;
                  }

                  String replaceWith =
                      callDirectlyOnFramework
                          ? newVarName
                          : shadowOfCall.getMethodSelect() + "(" + newVarName + ")";

                  possibleFixes.put(
                      replaceNode, possibleFixes.new ReplacementFix(identifierTree, replaceWith));
                }
                return super.visitIdentifier(identifierTree, aVoid);
              }

              private boolean isLeftSideOfAssignment(IdentifierTree identifierTree) {
                Tree parent = getCurrentPath().getParentPath().getLeaf();
                if (parent instanceof AssignmentTree) {
                  return identifierTree.equals(((AssignmentTree) parent).getVariable());
                }
                return false;
              }
            }.scan(compilationUnit, null);
          }
          break;

        case ASSIGNMENT: // this.shadowType = shadowOf(type);
          {
            // shadow is being assigned to a field or variable; don't do that!
            JCAssign assignment = (JCAssign) parent;
            Symbol fieldSymbol = getSymbol(assignment.lhs);

            String oldFieldName = assignment.lhs.toString();
            String remappedName = varRemapping.get(fieldSymbol);

            // since it's being declared here, no danger of a collision on this var name...
            knownFields.remove(oldFieldName);

            String newFieldName =
                remappedName == null ? pickNewName(shadowOfArg, oldFieldName, this) : remappedName;
            varRemapping.put(fieldSymbol, newFieldName);

            // ... but be careful not to collide with it later.
            knownLocalVars.add(newFieldName);

            // local variable declaration should have been handled above in the VARIABLE case;
            // just strip shadowOf() and assign it to the de-shadowed variable.
            if (fieldSymbol.getKind() == ElementKind.LOCAL_VARIABLE) {
              if (newFieldName.equals(shadowOfArg.toString())) {
                // assigning to self, don't bother
                TreePath assignmentPath = TreePath.getPath(compilationUnit, assignment);
                Tree assignmentParent = assignmentPath.getParentPath().getLeaf();
                if (assignmentParent instanceof ExpressionStatementTree) {
                  possibleFixes.fixByDeleting(assignmentParent);
                }
              } else {
                possibleFixes.fixByReplacing(
                    assignment, newFieldName + " = " + shadowOfArg.toString());
              }
              break;
            }

            Symbol shadowOfArgSym = getSymbol(shadowOfArg);
            ElementKind shadowOfArgDomicile =
                shadowOfArgSym == null
                    ? ElementKind.OTHER // it's probably an expression, not a var...
                    : shadowOfArgSym.getKind();
            boolean namesAreSame = newFieldName.equals(shadowOfArg.toString());

            boolean useExistingField =
                shadowOfArgDomicile == ElementKind.FIELD
                    && namesAreSame
                    && !isMethodParam(ASTHelpers.getSymbol(shadowOfArg), state.getPath());

            if (useExistingField) {
              fixVar(fieldSymbol, state, possibleFixes).delete();

              ExpressionStatementTree enclosingNode =
                  ASTHelpers.findEnclosingNode(
                      TreePath.getPath(compilationUnit, assignment), ExpressionStatementTree.class);
              if (enclosingNode != null) {
                possibleFixes.fixByDeleting(enclosingNode);
              }
            } else {
              Type shadowedType = getUpperBound(shadowOfArgType, state);
              String shadowedTypeName =
                  SuggestedFixes.prettyType(state, possibleFixes.fixBuilder, shadowedType);
              fixVar(fieldSymbol, state, possibleFixes)
                  .setName(newFieldName)
                  .setTypeName(shadowedTypeName)
                  .setRenameUses(false)
                  .modify();

              String thisStr = "";
              if (assignment.lhs.toString().startsWith("this.")
                  || (shadowOfArgDomicile == ElementKind.LOCAL_VARIABLE && namesAreSame)) {
                thisStr = "this.";
              }

              possibleFixes.fixByReplacing(
                  assignment, thisStr + newFieldName + " = " + shadowOfArg);
            }

            TreePath containingBlock = findParentOfKind(state, Kind.BLOCK);
            if (containingBlock != null) {
              // replace shadow field reference with `nonShadowInstance` or
              // `shadowOf(nonShadowInstance)` as appropriate.
              new TreePathScanner<Void, Void>() {
                @Override
                public Void visitMemberSelect(MemberSelectTree memberSelectTree, Void aVoid) {
                  maybeReplaceFieldRef(memberSelectTree.getExpression());

                  return super.visitMemberSelect(memberSelectTree, aVoid);
                }

                @Override
                public Void visitIdentifier(IdentifierTree identifierTree, Void aVoid) {
                  maybeReplaceFieldRef(identifierTree);

                  return super.visitIdentifier(identifierTree, aVoid);
                }

                private void maybeReplaceFieldRef(ExpressionTree subject) {
                  Symbol symbol = getSymbol(subject);
                  if (symbol != null && symbol.getKind() == ElementKind.FIELD) {
                    TreePath subjectPath = TreePath.getPath(compilationUnit, subject);

                    if (symbol.equals(fieldSymbol) && isPartOfMethodInvocation(subjectPath)) {
                      String fieldRef =
                          subject.toString().startsWith("this.")
                              ? "this." + newFieldName
                              : newFieldName;

                      JCTree replaceNode = (JCTree) subject;
                      Tree container = subjectPath.getParentPath().getLeaf();
                      if (container instanceof JCFieldAccess) {
                        JCFieldAccess fieldAccess = (JCFieldAccess) container;
                        JCMethodInvocation newShadowOfCall =
                            createSyntheticShadowAccess(shadowOfCall, newFieldName, symbol, state);
                        replaceFieldSelected(fieldAccess, newShadowOfCall, state);
                        replaceNode = newShadowOfCall;
                      }

                      String replaceWith =
                          shouldCallDirectlyOnFramework(subjectPath)
                              ? fieldRef
                              : shadowOfCall.getMethodSelect() + "(" + fieldRef + ")";
                      possibleFixes.put(
                          replaceNode, possibleFixes.new ReplacementFix(subject, replaceWith));
                    }
                  }
                }
              }.scan(compilationUnit, null);
            }
          }
          break;

        case MEMBER_SELECT: // shadowOf(type).method();
          {
            if (shouldCallDirectlyOnFramework(state.getPath())) {
              if (!isInSyntheticShadowAccess(state)) {
                possibleFixes.fixByReplacing(shadowOfCall, shadowOfArg.toString());
              }
            }
          }
          break;

        case TYPE_CAST:
          System.out.println("WARN: not sure what to do with " + parent.getKind() + ": " + parent);
          break;

        default:
          throw new RuntimeException(
              "not sure what to do with " + parent.getKind() + ": " + parent);
      }
    }

    private void replaceFieldSelected(
        JCFieldAccess fieldAccess, JCMethodInvocation newShadowOfCall, VisitorState state) {
      int priorStartPosition = fieldAccess.selected.getStartPosition();
      int priorEndPosition = getEndPosition(state, fieldAccess.selected);

      fieldAccess.selected = newShadowOfCall;

      newShadowOfCall.pos = priorStartPosition;
      if (newShadowOfCall.meth instanceof JCIdent) {
        ((JCIdent) newShadowOfCall.meth).pos = priorStartPosition;
      }
      setEndPosition(state, newShadowOfCall, priorEndPosition);
    }

    private int getEndPosition(VisitorState state, JCTree node) {
      return state.getEndPosition(node);
    }

    private void setEndPosition(VisitorState state, JCTree tree, int endPosition) {
      JCCompilationUnit compilationUnit = (JCCompilationUnit) state.getPath().getCompilationUnit();
      compilationUnit.endPositions.storeEnd(tree, endPosition);
    }

    @Override
    public Void visitClass(ClassTree classTree, VisitorState visitorState) {
      boolean priorInShadowClass = inShadowClass;
      inShadowClass = hasAnnotation(classTree, Implements.class, visitorState);
      try {
        return super.visitClass(classTree, visitorState);
      } finally {
        inShadowClass = priorInShadowClass;
      }
    }

    @Override
    public Void visitVariable(VariableTree node, VisitorState state) {
      if (getSymbol(node).getKind() == ElementKind.LOCAL_VARIABLE) {
        knownLocalVars.add(node.getName().toString());
      } else {
        knownFields.add(node.getName().toString());
      }
      return super.visitVariable(node, state);
    }

    @Override
    public Void visitMethod(MethodTree node, VisitorState state) {
      knownLocalVars.clear();
      return super.visitMethod(node, state);
    }

    @Override
    public Void visitMethodInvocation(MethodInvocationTree tree, VisitorState state) {
      VisitorState nowState = state.withPath(TreePath.getPath(state.getPath(), tree));

      if (!inShadowClass && shadowStaticMatcher.matches(tree, nowState)) {
        // Replace ShadowXxx.method() with Xxx.method() where possible...
        JCFieldAccess methodSelect = (JCFieldAccess) tree.getMethodSelect();
        ClassSymbol owner = (ClassSymbol) methodSelect.sym.owner;

        ClassType shadowedClass = determineShadowedClassName(owner, nowState);
        String shadowedTypeName =
            SuggestedFixes.prettyType(state, possibleFixes.fixBuilder, shadowedClass);
        possibleFixes.fixByReplacing(methodSelect.selected, shadowedTypeName);
      }

      if (!inShadowClass && shadowOfMatcher.matches(tree, nowState)) {
        matchedShadowOf(tree, nowState);
      }

      return super.visitMethodInvocation(tree, nowState);
    }
  }

  private static boolean isInSyntheticShadowAccess(VisitorState state) {
    Tree myParent = state.getPath().getParentPath().getLeaf();
    if (myParent instanceof JCFieldAccess) {
      JCFieldAccess myParentFieldAccess = (JCFieldAccess) myParent;
      return (myParentFieldAccess.selected.type instanceof UnknownType);
    }
    return false;
  }

  private static JCMethodInvocation createSyntheticShadowAccess(
      MethodInvocationTree shadowOfCall,
      String newFieldName,
      Symbol originalSymbol,
      VisitorState state) {
    TreeMaker treeMaker = state.getTreeMaker();

    Symbol newSymbol =
        createSymbol(
            originalSymbol,
            state.getName(newFieldName),
            ((JCExpression) shadowOfCall.getArguments().get(0)).type);

    JCExpression methodSelect = (JCExpression) shadowOfCall.getMethodSelect();
    if (methodSelect instanceof JCIdent) {
      // clone so start pos can be changed...
      methodSelect = treeMaker.Ident(((JCIdent) shadowOfCall.getMethodSelect()).sym);
    }
    JCMethodInvocation callShadowOf =
        treeMaker.Apply(
            null,
            methodSelect,
            com.sun.tools.javac.util.List.of(createIdent(treeMaker, newSymbol)));
    callShadowOf.type = ((JCMethodInvocation) shadowOfCall).type;
    return callShadowOf;
  }

  private static Symbol createSymbol(Symbol oldSymbol, Name newName, Type newType) {
    Symbol newSymbol = oldSymbol.clone(oldSymbol.owner);
    newSymbol.name = newName;
    newSymbol.type = newType;
    return newSymbol;
  }

  private static JCIdent createIdent(TreeMaker treeMaker, Symbol symbol) {
    JCIdent newFieldIdent = treeMaker.Ident(symbol.name);
    newFieldIdent.type = symbol.type;
    newFieldIdent.sym = symbol;
    return newFieldIdent;
  }

  private static boolean isMethodParam(Symbol fieldSymbol, TreePath path) {
    JCMethodDecl enclosingMethodDecl = ASTHelpers.findEnclosingNode(path, JCMethodDecl.class);
    if (enclosingMethodDecl != null) {
      for (JCVariableDecl param : enclosingMethodDecl.getParameters()) {
        if (getSymbol(param).equals(fieldSymbol)) {
          return true;
        }
      }
    }
    return false;
  }

  private static Type getUpperBound(Type type, VisitorState state) {
    return ASTHelpers.getUpperBound(type.tsym.type, Types.instance(state.context));
  }

  private static TreePath findParentOfKind(VisitorState state, Kind kind) {
    TreePath path = state.getPath();
    while (path != null && path.getLeaf().getKind() != kind) {
      path = path.getParentPath();
    }
    return path;
  }

  private static Type getExpressionType(ExpressionTree shadowOfArg) {
    Type shadowOfArgType;
    if (shadowOfArg instanceof JCNewClass) {
      shadowOfArgType = ((JCNewClass) shadowOfArg).type;
    } else if (shadowOfArg instanceof JCTree) {
      shadowOfArgType = ((JCTree) shadowOfArg).type;
    } else {
      throw new RuntimeException("huh? " + shadowOfArg.getClass() + " for " + shadowOfArg);
    }
    return shadowOfArgType;
  }

  private static String pickNewName(
      ExpressionTree shadowOfArg, String oldVarName, ShadowInliner shadowInliner) {
    String newVarName = oldVarName;

    if (shadowOfArg.getKind() == Kind.IDENTIFIER) {
      // no need to worry about a name collision in this case...
      return shadowOfArg.toString();
    } else if (newVarName.equals("shadow")) {
      newVarName = varNameFromType(getExpressionType(shadowOfArg));
    } else if (newVarName.startsWith("shadow")) {
      newVarName = newVarName.substring(6, 7).toLowerCase() + newVarName.substring(7);
    } else if (newVarName.endsWith("Shadow")) {
      newVarName = newVarName.substring(0, newVarName.length() - "Shadow".length());
    }

    // if the new name is already in use, find a unique name...
    String origNewVarName = newVarName;
    for (int i = 2;
        shadowInliner.knownFields.contains(newVarName)
            || shadowInliner.knownLocalVars.contains(newVarName);
        i++) {
      newVarName = origNewVarName + i;
    }

    return newVarName;
  }

  private static String varNameFromType(Type type) {
    String simpleName = type.tsym.name.toString();
    return simpleName.substring(0, 1).toLowerCase() + simpleName.substring(1);
  }

  private static ClassType determineShadowedClassName(ClassSymbol owner, VisitorState state) {
    for (Compound compound : owner.getAnnotationMirrors()) {
      if (Implements.class.getName().equals(compound.getAnnotationType().toString())) {
        for (Entry<MethodSymbol, Attribute> entry : compound.getElementValues().entrySet()) {
          String key = entry.getKey().name.toString();
          Attribute value = entry.getValue();

          if (key.equals("value")) {
            TypeMirror typeMirror = valueVisitor.visit(value);
            if (!typeMirror.equals(state.getTypeFromString("void"))) {
              return (ClassType) typeMirror;
            }
          }

          if (key.equals("className")) {
            String name = classNameVisitor.visit(value);
            if (!name.isEmpty()) {
              return (ClassType) state.getTypeFromString(name);
            }
          }
        }
      }
    }

    throw new RuntimeException("couldn't determine shadowed class for " + owner);
  }

  public static AnnotationValueVisitor<TypeMirror, Void> valueVisitor =
      new SimpleAnnotationValueVisitor6<TypeMirror, Void>() {
        @Override
        public TypeMirror visitType(TypeMirror t, Void arg) {
          return t;
        }
      };

  public static AnnotationValueVisitor<String, Void> classNameVisitor =
      new SimpleAnnotationValueVisitor6<String, Void>() {
        @Override
        public String visitString(String s, Void arg) {
          return s;
        }
      };

  private static boolean isPartOfMethodInvocation(TreePath idPath) {
    Kind parentKind = idPath.getParentPath().getLeaf().getKind();
    if (parentKind == Kind.METHOD_INVOCATION) {
      // must be an argument
      return true;
    }
    if (parentKind == Kind.MEMBER_SELECT) {
      Tree maybeMethodInvocation = idPath.getParentPath().getParentPath().getLeaf();
      // likely the target of the method invocation
      return maybeMethodInvocation.getKind() == Kind.METHOD_INVOCATION;
    }
    return false;
  }

  private static boolean shouldCallDirectlyOnFramework(TreePath idPath) {
    if (idPath.getParentPath().getLeaf().getKind() == Kind.MEMBER_SELECT) {
      Tree maybeMethodInvocation = idPath.getParentPath().getParentPath().getLeaf();
      if (maybeMethodInvocation.getKind() == Kind.METHOD_INVOCATION) {
        MethodInvocationTree methodInvocation = (MethodInvocationTree) maybeMethodInvocation;
        MethodSymbol methodSym = getSymbol(methodInvocation);
        if (methodSym == null) {
          return false;
        }
        Implementation implAnnotation = methodSym.getAnnotation(Implementation.class);
        if (implAnnotation != null) {
          int minSdk = implAnnotation.minSdk();
          int maxSdk = implAnnotation.maxSdk();

          // if minSdk or maxSdk is set (or the method is marked @HiddenApi), this method might
          // not be available at every SDK level (or at all).
          return (minSdk == Implementation.DEFAULT_SDK || minSdk <= 16)
              && maxSdk == Implementation.DEFAULT_SDK
              && methodSym.getAnnotation(HiddenApi.class) == null;
        }
      }
    }
    return false;
  }

  /**
   * Renames the given {@link Symbol} and its usages in the current compilation unit to {@code
   * newName}.
   */
  static VariableFixer fixVar(Symbol symbol, VisitorState state, PossibleFixes possibleFixes) {
    return new VariableFixer(symbol, state, possibleFixes);
  }

  private static class VariableFixer {

    private final Symbol symbol;
    private final VisitorState state;
    private final PossibleFixes possibleFixes;
    private boolean renameUses = true;
    private String newName;
    private String newTypeName;

    public VariableFixer(Symbol symbol, VisitorState state, PossibleFixes possibleFixes) {
      this.symbol = symbol;
      this.state = state;
      this.possibleFixes = possibleFixes;
    }

    VariableFixer setName(String newName) {
      this.newName = newName;
      return this;
    }

    VariableFixer setTypeName(String newTypeName) {
      this.newTypeName = newTypeName;
      return this;
    }

    VariableFixer setRenameUses(boolean renameUses) {
      this.renameUses = renameUses;
      return this;
    }

    void modify() {
      new TreePathScanner<Void, Void>() {
        @Override
        public Void visitVariable(VariableTree variableTree, Void v) {
          if (getSymbol(variableTree).equals(symbol)) {
            String name = variableTree.getName().toString();
            // For a lambda parameter without explicit type, it will return null.
            String source = state.getSourceForNode(variableTree.getType());
            if (newTypeName != null) {
              possibleFixes.fixByReplacing(variableTree.getType(), newTypeName);
            }

            if (newName != null && !newName.equals(name)) {
              int typeLength = source == null ? 0 : source.length();
              int pos =
                  ((JCTree) variableTree).getStartPosition()
                      + state.getSourceForNode(variableTree).indexOf(name, typeLength);
              possibleFixes.fixByReplacing(pos, pos + name.length(), newName);
            }
          }

          return super.visitVariable(variableTree, v);
        }
      }.scan(state.getPath().getCompilationUnit(), null);

      if (newName != null && renameUses) {
        ((JCTree) state.getPath().getCompilationUnit())
            .accept(
                new com.sun.tools.javac.tree.TreeScanner() {
                  @Override
                  public void visitIdent(JCTree.JCIdent tree) {
                    if (symbol.equals(getSymbol(tree))) {
                      possibleFixes.fixByReplacing(tree, newName);
                    }
                  }
                });
      }
    }

    void delete() {
      new TreePathScanner<Void, Void>() {
        @Override
        public Void visitVariable(VariableTree variableTree, Void v) {
          if (getSymbol(variableTree).equals(symbol)) {
            possibleFixes.fixByDeleting(variableTree);
          }

          return super.visitVariable(variableTree, v);
        }
      }.scan(state.getPath().getCompilationUnit(), null);
    }
  }

  private static Tree removeCastIfUnnecessary(JCTypeCast cast, VisitorState state) {
    if (cast.type.tsym.equals(cast.expr.type.tsym)) {
      Tree grandparent = findParent(cast, state);
      switch (grandparent.getKind()) {
        case VARIABLE:
          JCVariableDecl variableDecl = (JCVariableDecl) grandparent;
          variableDecl.init = cast.expr;
          break;
        case ASSIGNMENT:
          JCAssign assignment = (JCAssign) grandparent;
          assignment.rhs = cast.expr;
          break;
        default:
          // ok
      }

      // point to the expression that was previously being cast
      return grandparent;
    } else {
      return cast;
    }
  }

  private static Tree findParent(Tree node, VisitorState state) {
    return TreePath.getPath(state.getPath().getCompilationUnit(), node).getParentPath().getLeaf();
  }

  static class PossibleFixes {

    private final Builder fixBuilder;
    private final JCCompilationUnit compilationUnit;
    private final Map<Tree, PossibleFix> map = new HashMap<>();
    private final List<PossibleFix> list = new ArrayList<>();

    public PossibleFixes(Builder builder, JCCompilationUnit compilationUnit) {
      fixBuilder = builder;
      this.compilationUnit = compilationUnit;
    }

    public void put(Tree tree, PossibleFix possibleFix) {
      if (tree != null) {
        PossibleFix priorFix = map.put(tree, possibleFix);
        if (priorFix != null) {
          list.remove(priorFix);
        }
      }

      list.add(possibleFix);
    }

    public void removeFixFor(Tree tree) {
      PossibleFix possibleFix = map.remove(tree);
      list.remove(possibleFix);
    }

    public void fixByReplacing(Tree tree, String value) {
      put(tree, new ReplacementFix(tree, value));
    }

    public void fixByDeleting(Tree tree) {
      put(tree, new DeletionFix(tree));
    }

    public void fixByReplacing(int startPos, int endPos, String replaceWith) {
      put(null, new PositionalReplacementFix(startPos, endPos, replaceWith));
    }

    public Fix getFix() {
      for (PossibleFix possibleFix : list) {
        possibleFix.applyFix(fixBuilder);
      }

      return fixBuilder.build();
    }

    abstract class PossibleFix {
      protected final Tree tree;
      protected final int startPosition;
      protected final int endPosition;
      private final Throwable trace;

      PossibleFix(Tree tree) {
        this.tree = tree;

        DiagnosticPosition position = (DiagnosticPosition) tree;
        this.startPosition = position.getStartPosition();
        this.endPosition = position.getEndPosition(compilationUnit.endPositions);

        this.trace = new RuntimeException();
      }

      PossibleFix(int startPosition, int endPosition) {
        this.tree = null;

        this.startPosition = startPosition;
        this.endPosition = endPosition;

        this.trace = new RuntimeException();
      }

      abstract void applyFix(Builder fixBuilder);

      @Override
      public String toString() {
        return "PossibleFix{"
            + "tree="
            + tree
            + ", startPosition="
            + startPosition
            + ", endPosition="
            + endPosition
            + '}';
      }
    }

    class ReplacementFix extends PossibleFix {

      private final String replaceWith;

      public ReplacementFix(Tree tree, String replaceWith) {
        super(tree);
        this.replaceWith = replaceWith;
      }

      @Override
      void applyFix(Builder fixBuilder) {
        fixBuilder.replace(tree, replaceWith);
      }

      @Override
      public String toString() {
        return super.toString() + " [replace with " + replaceWith + "]";
      }
    }

    private class PositionalReplacementFix extends PossibleFix {

      private final String replaceWith;

      public PositionalReplacementFix(int startPos, int endPos, String replaceWith) {
        super(startPos, endPos);
        this.replaceWith = replaceWith;
      }

      @Override
      void applyFix(Builder fixBuilder) {
        fixBuilder.replace(startPosition, endPosition, replaceWith);
      }

      @Override
      public String toString() {
        return super.toString() + " [replace with " + replaceWith + "]";
      }
    }

    class DeletionFix extends PossibleFix {

      public DeletionFix(Tree tree) {
        super(tree);
      }

      @Override
      void applyFix(Builder fixBuilder) {
        fixBuilder.delete(tree);
      }

      @Override
      public String toString() {
        return super.toString() + " [delete]";
      }
    }
  }
}


package org.robolectric.shadows;

import static android.os.Build.VERSION_CODES.M;
import static android.os.Build.VERSION_CODES.N;
import static org.robolectric.RuntimeEnvironment.application;

import android.os.UserManager;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import com.google.common.base.Preconditions;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import org.robolectric.annotation.HiddenApi;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.shadow.api.Shadow;

/**
 * Fake implementation of {@link android.os.storage.StorageManager}
 */
@Implements(StorageManager.class)
public class ShadowStorageManager {

  private static boolean isFileEncryptionSupported = true;
  private final List<StorageVolume> storageVolumeList = new ArrayList<>();

  @Implementation(minSdk = M)
  protected static StorageVolume[] getVolumeList(int userId, int flags) {
    return new StorageVolume[0];
  }

  /**
   * Gets the volume list from {@link #getVolumeList(int, int)}
   *
   * @return volume list
   */
  public StorageVolume[] getVolumeList() {
    return getVolumeList(0, 0);
  }

  /**
   * Adds a {@link StorageVolume} to the list returned by {@link #getStorageVolumes()}.
   *
   * @param StorageVolume to add to list
   */
  public void addStorageVolume(StorageVolume storageVolume) {
    Preconditions.checkNotNull(storageVolume);
    storageVolumeList.add(storageVolume);
  }

  /**
   * Returns the storage volumes configured via {@link #addStorageVolume()}.
   *
   * @return StorageVolume list
   */
  @Implementation(minSdk = N)
  protected List<StorageVolume> getStorageVolumes() {
    return storageVolumeList;
  }

  /** Clears the storageVolumeList. */
  public void resetStorageVolumeList() {
    storageVolumeList.clear();
  }

  /**
   * Checks whether File belongs to any {@link StorageVolume} in the list returned by {@link
   * #getStorageVolumes()}.
   *
   * @param File to check
   * @return StorageVolume for the file
   */
  @Implementation(minSdk = N)
  public StorageVolume getStorageVolume(File file) {
    for (StorageVolume volume : storageVolumeList) {
      File volumeFile = volume.getPathFile();
      if (file.getAbsolutePath().equals(volumeFile.getAbsolutePath())) {
        return volume;
      }
    }
    return null;
  }

  @HiddenApi
  @Implementation(minSdk = N)
  protected static boolean isFileEncryptedNativeOrEmulated() {
    return isFileEncryptionSupported;
  }

  /**
   * Setter for {@link #isFileEncryptedNativeOrEmulated()}
   *
   * @param isSupported a boolean value to set file encrypted native or not
   */
  public void setFileEncryptedNativeOrEmulated(boolean isSupported) {
    isFileEncryptionSupported = isSupported;
  }

  @HiddenApi
  @Implementation(minSdk = N)
  protected static boolean isUserKeyUnlocked(int userId) {
    ShadowUserManager extract = Shadow.extract(application.getSystemService(UserManager.class));
    return extract.isUserUnlocked();
  }
}


package org.robolectric.shadows;

import static android.os.Build.VERSION_CODES.KITKAT_WATCH;
import static android.os.Build.VERSION_CODES.LOLLIPOP;
import static android.os.Build.VERSION_CODES.M;
import static android.os.Build.VERSION_CODES.N;
import static android.os.Build.VERSION_CODES.N_MR1;
import static org.robolectric.shadow.api.Shadow.directlyOn;
import static org.robolectric.shadows.ShadowAssetManager.legacyShadowOf;

import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.content.res.Resources.NotFoundException;
import android.content.res.ResourcesImpl;
import android.content.res.TypedArray;
import android.content.res.XmlResourceParser;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.ParcelFileDescriptor;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.LongSparseArray;
import android.util.TypedValue;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.HiddenApi;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.RealObject;
import org.robolectric.annotation.Resetter;
import org.robolectric.res.Plural;
import org.robolectric.res.PluralRules;
import org.robolectric.res.ResName;
import org.robolectric.res.ResType;
import org.robolectric.res.ResourceTable;
import org.robolectric.res.TypedResource;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowLegacyResourcesImpl.ShadowLegacyThemeImpl;
import org.robolectric.util.ReflectionHelpers;
import org.robolectric.util.ReflectionHelpers.ClassParameter;

@Implements(Resources.class)
public class ShadowResources {

  private static Resources system = null;
  private static List<LongSparseArray<?>> resettableArrays;

  @RealObject Resources realResources;

  @Resetter
  public static void reset() {
    if (resettableArrays == null) {
      resettableArrays = obtainResettableArrays();
    }
    for (LongSparseArray<?> sparseArray : resettableArrays) {
      sparseArray.clear();
    }
    system = null;

    ReflectionHelpers.setStaticField(Resources.class, "mSystem", null);
  }

  @Implementation
  protected static Resources getSystem() {
    if (system == null) {
      AssetManager assetManager = AssetManager.getSystem();
      DisplayMetrics metrics = new DisplayMetrics();
      Configuration config = new Configuration();
      system = new Resources(assetManager, metrics, config);
    }
    return system;
  }

  @Implementation
  protected TypedArray obtainAttributes(AttributeSet set, int[] attrs) {
    if (isLegacyAssetManager()) {
      return legacyShadowOf(realResources.getAssets())
          .attrsToTypedArray(realResources, set, attrs, 0, 0, 0);
    } else {
      return directlyOn(realResources, Resources.class).obtainAttributes(set, attrs);
    }
  }

  @Implementation
  protected String getQuantityString(int id, int quantity, Object... formatArgs)
      throws Resources.NotFoundException {
    if (isLegacyAssetManager()) {
      String raw = getQuantityString(id, quantity);
      return String.format(Locale.ENGLISH, raw, formatArgs);
    } else {
      return directlyOn(realResources, Resources.class).getQuantityString(id, quantity, formatArgs);
    }
  }

  @Implementation
  protected String getQuantityString(int resId, int quantity) throws Resources.NotFoundException {
    if (isLegacyAssetManager()) {
      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());

      TypedResource typedResource = shadowAssetManager.getResourceTable()
          .getValue(resId, shadowAssetManager.config);
      if (typedResource != null && typedResource instanceof PluralRules) {
        PluralRules pluralRules = (PluralRules) typedResource;
        Plural plural = pluralRules.find(quantity);

        if (plural == null) {
          return null;
        }

        TypedResource<?> resolvedTypedResource = shadowAssetManager.resolve(
            new TypedResource<>(plural.getString(), ResType.CHAR_SEQUENCE, pluralRules.getXmlContext()),
            shadowAssetManager.config, resId);
        return resolvedTypedResource == null ? null : resolvedTypedResource.asString();
      } else {
        return null;
      }
    } else {
      return directlyOn(realResources, Resources.class).getQuantityString(resId, quantity);
    }
  }

  @Implementation
  protected InputStream openRawResource(int id) throws Resources.NotFoundException {
    if (isLegacyAssetManager()) {
      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());
      ResourceTable resourceTable = shadowAssetManager.getResourceTable();
      InputStream inputStream = resourceTable.getRawValue(id, shadowAssetManager.config);
      if (inputStream == null) {
        throw newNotFoundException(id);
      } else {
        return inputStream;
      }
    } else {
      return directlyOn(realResources, Resources.class).openRawResource(id);
    }
  }

  /**
   * Since {@link AssetFileDescriptor}s are not yet supported by Robolectric, {@code null} will be
   * returned if the resource is found. If the resource cannot be found, {@link
   * Resources.NotFoundException} will be thrown.
   */
  @Implementation
  protected AssetFileDescriptor openRawResourceFd(int id) throws Resources.NotFoundException {
    if (isLegacyAssetManager()) {
      InputStream inputStream = openRawResource(id);
      if (!(inputStream instanceof FileInputStream)) {
        // todo fixme
        return null;
      }

      FileInputStream fis = (FileInputStream) inputStream;
      try {
        return new AssetFileDescriptor(ParcelFileDescriptor.dup(fis.getFD()), 0,
            fis.getChannel().size());
      } catch (IOException e) {
        throw newNotFoundException(id);
      }
    } else {
      return directlyOn(realResources, Resources.class).openRawResourceFd(id);
    }
  }

  private Resources.NotFoundException newNotFoundException(int id) {
    ResourceTable resourceTable = legacyShadowOf(realResources.getAssets()).getResourceTable();
    ResName resName = resourceTable.getResName(id);
    if (resName == null) {
      return new Resources.NotFoundException("resource ID #0x" + Integer.toHexString(id));
    } else {
      return new Resources.NotFoundException(resName.getFullyQualifiedName());
    }
  }

  @Implementation
  protected TypedArray obtainTypedArray(int id) throws Resources.NotFoundException {
    if (isLegacyAssetManager()) {
      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());
      TypedArray typedArray = shadowAssetManager.getTypedArrayResource(realResources, id);
      if (typedArray != null) {
        return typedArray;
      } else {
        throw newNotFoundException(id);
      }
    } else {
      return directlyOn(realResources, Resources.class).obtainTypedArray(id);
    }
  }

  @HiddenApi
  @Implementation
  protected XmlResourceParser loadXmlResourceParser(int resId, String type)
      throws Resources.NotFoundException {
    if (isLegacyAssetManager()) {
      ShadowLegacyAssetManager shadowAssetManager = legacyShadowOf(realResources.getAssets());
      return shadowAssetManager.loadXmlResourceParser(resId, type);
    } else {
      return directlyOn(realResources, Resources.class, "loadXmlResourceParser",
          ClassParameter.from(int.class, resId),
          ClassParameter.from(String.class, type));
    }
  }

  @HiddenApi
  @Implementation
  protected XmlResourceParser loadXmlResourceParser(
      String file, int id, int assetCookie, String type) throws Resources.NotFoundException {
    if (isLegacyAssetManager()) {
      return loadXmlResourceParser(id, type);
    } else {
      return directlyOn(realResources, Resources.class, "loadXmlResourceParser",
          ClassParameter.from(String.class, file),
          ClassParameter.from(int.class, id),
          ClassParameter.from(int.class, assetCookie),
          ClassParameter.from(String.class, type));
    }
  }

  @HiddenApi
  @Implementation(maxSdk = KITKAT_WATCH)
  protected Drawable loadDrawable(TypedValue value, int id) {
    Drawable drawable = directlyOn(realResources, Resources.class, "loadDrawable",
        ClassParameter.from(TypedValue.class, value),
        ClassParameter.from(int.class, id));
    setCreatedFromResId(realResources, id, drawable);
    return drawable;
  }

  @Implementation(minSdk = LOLLIPOP, maxSdk = N_MR1)
  protected Drawable loadDrawable(TypedValue value, int id, Resources.Theme theme)
      throws Resources.NotFoundException {
    Drawable drawable = directlyOn(realResources, Resources.class, "loadDrawable",
        ClassParameter.from(TypedValue.class, value), ClassParameter.from(int.class, id), ClassParameter.from(Resources.Theme.class, theme));
    setCreatedFromResId(realResources, id, drawable);
    return drawable;
  }

  private static List<LongSparseArray<?>> obtainResettableArrays() {
    List<LongSparseArray<?>> resettableArrays = new ArrayList<>();
    Field[] allFields = Resources.class.getDeclaredFields();
    for (Field field : allFields) {
      if (Modifier.isStatic(field.getModifiers()) && field.getType().equals(LongSparseArray.class)) {
        field.setAccessible(true);
        try {
          LongSparseArray<?> longSparseArray = (LongSparseArray<?>) field.get(null);
          if (longSparseArray != null) {
            resettableArrays.add(longSparseArray);
          }
        } catch (IllegalAccessException e) {
          throw new RuntimeException(e);
        }
      }
    }
    return resettableArrays;
  }

  public static abstract class ShadowTheme {

    public static class Picker extends ResourceModeShadowPicker<ShadowTheme> {

      public Picker() {
        super(ShadowLegacyTheme.class, null, null);
      }
    }
  }

  @Implements(value = Resources.Theme.class, shadowPicker = ShadowTheme.Picker.class)
  public static class ShadowLegacyTheme extends ShadowTheme {
    @RealObject Resources.Theme realTheme;

    long getNativePtr() {
      if (RuntimeEnvironment.getApiLevel() >= N) {
        ResourcesImpl.ThemeImpl themeImpl = ReflectionHelpers.getField(realTheme, "mThemeImpl");
        return ((ShadowLegacyThemeImpl) Shadow.extract(themeImpl)).getNativePtr();
      } else {
        return ((Number) ReflectionHelpers.getField(realTheme, "mTheme")).longValue();
      }
    }

    @Implementation(maxSdk = M)
    protected TypedArray obtainStyledAttributes(int[] attrs) {
      return obtainStyledAttributes(0, attrs);
    }

    @Implementation(maxSdk = M)
    protected TypedArray obtainStyledAttributes(int resid, int[] attrs)
        throws Resources.NotFoundException {
      return obtainStyledAttributes(null, attrs, 0, resid);
    }

    @Implementation(maxSdk = M)
    protected TypedArray obtainStyledAttributes(
        AttributeSet set, int[] attrs, int defStyleAttr, int defStyleRes) {
      return getShadowAssetManager().attrsToTypedArray(getResources(), set, attrs, defStyleAttr, getNativePtr(), defStyleRes);
    }

    private ShadowLegacyAssetManager getShadowAssetManager() {
      return legacyShadowOf(getResources().getAssets());
    }

    private Resources getResources() {
      return ReflectionHelpers.getField(realTheme, "this$0");
    }
  }

  static void setCreatedFromResId(Resources resources, int id, Drawable drawable) {
    // todo: this kinda sucks, find some better way...
    if (drawable != null && Shadow.extract(drawable) instanceof ShadowDrawable) {
      ShadowDrawable shadowDrawable = Shadow.extract(drawable);
      shadowDrawable.createdFromResId = id;
      if (drawable instanceof BitmapDrawable) {
        Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
        if (bitmap != null  && Shadow.extract(bitmap) instanceof ShadowBitmap) {
          ShadowBitmap shadowBitmap = Shadow.extract(bitmap);
          if (shadowBitmap.createdFromResId == -1) {
            String resourceName;
            try {
              resourceName = resources.getResourceName(id);
            } catch (NotFoundException e) {
              resourceName = "Unknown resource #0x" + Integer.toHexString(id);
            }
            shadowBitmap.setCreatedFromResId(id, resourceName);
          }
        }
      }
    }
  }

  private boolean isLegacyAssetManager() {
    return ShadowAssetManager.useLegacy();
  }

  @Implements(Resources.NotFoundException.class)
  public static class ShadowNotFoundException {
    @RealObject Resources.NotFoundException realObject;

    private String message;

    @Implementation
    protected void __constructor__() {}

    @Implementation
    protected void __constructor__(String name) {
      this.message = name;
    }

    @Override @Implementation
    public String toString() {
      return realObject.getClass().getName() + ": " + message;
    }
  }
}