SpeakEasyProtocol.java

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

package androidx.test.services.speakeasy;

import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Parcel;
import android.os.ResultReceiver;
import android.util.Log;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/**
 * SpeakEasyProtocol abstracts away sending commands / interpreting responses from speakeasy via
 * bundles.
 *
 * <p>SpeakEasy allows the registration, query, removal of IBinders from the shell user to android
 * apps.
 *
 * <p>This bypasses the Android platform's typical dependency and lifecycle management of Services
 * and IPC. Using Service objects defined in your Android manifest is the proper way to do IPC in
 * Android and these mechanisms should only be used in test.
 *
 * <p>The dependencies of this class should be kept to a minimum and it should remain possible to
 * use this class outside of an apk.
 */
public final class SpeakEasyProtocol {
  private static final String TAG = SpeakEasyProtocol.class.getName();

  public final int type;

  public static final int PUBLISH_TYPE = 0;
  public static final int PUBLISH_RESULT_TYPE = 1;
  public static final int REMOVE_TYPE = 2;
  public static final int FIND_TYPE = 3;
  public static final int FIND_RESULT_TYPE = 4;

  /** Set based on type. */
  public final Publish publish;

  public final PublishResult publishResult;
  public final Remove remove;
  public final Find find;
  public final FindResult findResult;

  private static final String TYPE_KEY = "sep_type";
  private static final Method GET_IBINDER;
  private static final Method PUT_IBINDER;

  private SpeakEasyProtocol(Publish p) {
    this.type = PUBLISH_TYPE;
    this.publish = p;
    this.publishResult = null;
    this.remove = null;
    this.find = null;
    this.findResult = null;
  }

  @Override
  public String toString() {
    return String.format(
        "SpeakEasyProtocol{ type: %d, publish: %s, publishResult: %s, remove: %s, find: %s,"
            + "findResult: %s }",
        type, publish, publishResult, remove, find, findResult);
  }

  private SpeakEasyProtocol(PublishResult pr) {
    this.type = PUBLISH_RESULT_TYPE;
    this.publish = null;
    this.publishResult = pr;
    this.remove = null;
    this.find = null;
    this.findResult = null;
  }

  private SpeakEasyProtocol(Remove r) {
    this.type = REMOVE_TYPE;
    this.publish = null;
    this.publishResult = null;
    this.remove = r;
    this.find = null;
    this.findResult = null;
  }

  private SpeakEasyProtocol(Find f) {
    this.type = FIND_TYPE;
    this.publish = null;
    this.publishResult = null;
    this.remove = null;
    this.find = f;
    this.findResult = null;
  }

  private SpeakEasyProtocol(FindResult fr) {
    this.type = FIND_RESULT_TYPE;
    this.publish = null;
    this.publishResult = null;
    this.remove = null;
    this.find = null;
    this.findResult = fr;
  }

  /**
   * Decodes a bundle into a SpeakEasyProtocol object.
   *
   * @param b a Bundle (nullable)
   * @return A SpeakEasyProtocol - or null if invalid.
   */
  public static SpeakEasyProtocol fromBundle(Bundle b) {
    if (null == b) {
      Log.w(TAG, "Null bundle");
      return null;
    }
    switch (b.getInt(TYPE_KEY, -1)) {
      case PUBLISH_TYPE:
        return Publish.fromBundle(b);
      case PUBLISH_RESULT_TYPE:
        return PublishResult.fromBundle(b);
      case REMOVE_TYPE:
        return Remove.fromBundle(b);
      case FIND_TYPE:
        return Find.fromBundle(b);
      case FIND_RESULT_TYPE:
        return FindResult.fromBundle(b);
      default:
        Log.w(TAG, "Invalid/missing sep_type: " + b.getInt(TYPE_KEY, -1));
        return null;
    }
  }

  /** Represents a publish command to speakeasy. */
  public static final class Publish {
    /** The key to publish this IBinder under. */
    public final String key;

    /** The IBinder to publish. */
    public final IBinder value;

    /** A ResultReceiver to handle the response or failure of publishing. */
    public final ResultReceiver resultReceiver;

    private static final String KEY_KEY = "sep_pub_key";
    private static final String IBINDER_KEY = "sep_pub_ib";
    private static final String RESULT_KEY = "sep_pub_rr";

    private Publish(String key, IBinder value, ResultReceiver resultReceiver) {
      this.key = key;
      this.value = value;
      this.resultReceiver = resultReceiver;
    }

    @Override
    public String toString() {
      return String.format(
          "Publish: {key: %s, value: %s, resultReceiver: %s}", key, value, resultReceiver);
    }

    private static SpeakEasyProtocol fromBundle(Bundle b) {
      Publish p =
          new Publish(
              b.getString(KEY_KEY),
              getBinder(b, IBINDER_KEY),
              (ResultReceiver) b.getParcelable(RESULT_KEY));
      if (null == p.key) {
        Log.w(TAG, String.format("'%s': not set", KEY_KEY));
        return null;
      }
      if (null == p.value) {
        Log.w(TAG, String.format("'%s': not set", IBINDER_KEY));
        return null;
      }
      if (null == p.resultReceiver) {
        Log.w(TAG, String.format("'%s': not set", RESULT_KEY));
        return null;
      }
      return new SpeakEasyProtocol(p);
    }

    /** Builds a publish command into a bundle. */
    public static Bundle asBundle(String key, IBinder ib, ResultReceiver rr) {
      Bundle b = new Bundle();
      b.putInt(TYPE_KEY, PUBLISH_TYPE);
      b.putString(KEY_KEY, checkNotNull(key));
      putBinder(b, IBINDER_KEY, checkNotNull(ib));
      b.putParcelable(RESULT_KEY, marshableReceiver(checkNotNull(rr)));
      return b;
    }
  }

  /** Represents a publish response from speakeasy. */
  public static final class PublishResult {
    /** The key that this message is about. */
    public final String key;

    /** Whether or not the IBinder was published. */
    public final boolean published;

    /** An error message if publishing failed. */
    public final String error;

    private static final String KEY_KEY = "sep_pr_key";
    private static final String PUBLISHED_KEY = "sep_pr_published";
    private static final String ERROR_KEY = "sep_pr_err";

    private PublishResult(String key, boolean published, String error) {
      this.key = key;
      this.published = published;
      this.error = error;
    }

    @Override
    public String toString() {
      return String.format(
          "PublishResult: {key: %s, published: %s, error: %s}", key, published, error);
    }

    /**
     * Encodes a publish result into a bundle.
     *
     * @param published if the IBinder has been published.
     * @param error a message to tell the caller about how broken things are.
     * @return a bundle
     * @throws NullPointerException if not published and error is null.
     */
    public static Bundle asBundle(String key, boolean published, String error) {
      Bundle b = new Bundle();
      b.putInt(TYPE_KEY, PUBLISH_RESULT_TYPE);
      checkNotNull(key);
      b.putString(KEY_KEY, key);

      if (!published) {
        checkNotNull(error);
        b.putString(ERROR_KEY, error);
      }
      b.putBoolean(PUBLISHED_KEY, published);
      return b;
    }

    private static SpeakEasyProtocol fromBundle(Bundle b) {
      PublishResult pr =
          new PublishResult(
              b.getString(KEY_KEY), b.getBoolean(PUBLISHED_KEY), b.getString(ERROR_KEY));
      if (null == pr.key) {
        Log.w(TAG, String.format("'%s': not set", KEY_KEY));
        return null;
      }
      return new SpeakEasyProtocol(pr);
    }
  }

  /** Represents a Find request to SpeakEasy. */
  public static class Find {
    /** The key to search for. */
    public final String key;

    /** A ResultReceiver to be called with the search results. */
    public final ResultReceiver resultReceiver;

    private static final String KEY_KEY = "sep_find_key";
    private static final String RESULT_KEY = "sep_find_rr";

    private Find(String key, ResultReceiver resultReceiver) {
      this.key = key;
      this.resultReceiver = resultReceiver;
    }

    @Override
    public String toString() {
      return String.format("Find: {key: %s, resultReceiver: %s}", key, resultReceiver);
    }

    private static SpeakEasyProtocol fromBundle(Bundle b) {
      Find f = new Find(b.getString(KEY_KEY), (ResultReceiver) b.getParcelable(RESULT_KEY));
      if (null == f.key) {
        Log.w(TAG, String.format("'%s': not set", KEY_KEY));
        return null;
      }
      if (null == f.resultReceiver) {
        Log.w(TAG, String.format("'%s': not set", RESULT_KEY));
        return null;
      }
      return new SpeakEasyProtocol(f);
    }

    /**
     * Encodes a find request into a bundle.
     *
     * @param key the key to search for.
     * @param rr the ResultReceiver to send the results to.
     * @return a bundle
     * @throws NullPointerException if you do not provide the right parameters.
     */
    public static Bundle asBundle(String key, ResultReceiver rr) {
      Bundle b = new Bundle();
      b.putInt(TYPE_KEY, FIND_TYPE);
      b.putString(KEY_KEY, checkNotNull(key));
      b.putParcelable(RESULT_KEY, marshableReceiver(checkNotNull(rr)));
      return b;
    }
  }

  /** The result of a find operation on SpeakEasy. */
  public static class FindResult {
    /** Whether or not the IBinder was found. */
    public final Boolean found;

    /** The IBinder which was found. */
    public final IBinder binder;

    /** An error that caused the search to fail. */
    public final String error;

    private static final String FOUND_KEY = "sep_fr_found";
    private static final String BINDER_KEY = "sep_fr_binder";
    private static final String ERROR_KEY = "sep_fr_error";

    private FindResult(boolean found, IBinder binder, String error) {
      this.found = found;
      this.binder = binder;
      this.error = error;
    }

    @Override
    public String toString() {
      return String.format("FindResult: {found: %s, binder: %s, error: %s}", found, binder, error);
    }

    private static SpeakEasyProtocol fromBundle(Bundle b) {
      FindResult fr =
          new FindResult(
              b.getBoolean(FOUND_KEY, false), getBinder(b, BINDER_KEY), b.getString(ERROR_KEY));
      return new SpeakEasyProtocol(fr);
    }

    /**
     * Encodes the result of a find operation into a bundle.
     *
     * @param found whether or not the IBinder was found
     * @param binder the located IBinder
     * @param error the problem finding the thing.
     * @return A bundle that can be converted into a SpeakEasyProtocol
     * @throws NullPointerException if a IBinder is not provide for a successful find or an error is
     *     not provided on a failure.
     */
    public static Bundle asBundle(boolean found, IBinder binder, String error) {
      Bundle b = new Bundle();
      b.putInt(TYPE_KEY, FIND_RESULT_TYPE);
      b.putBoolean(FOUND_KEY, found);
      if (!found) {
        b.putString(ERROR_KEY, checkNotNull(error));
        return b;
      }
      putBinder(b, BINDER_KEY, checkNotNull(binder));
      return b;
    }
  }

  /** Indicates a request to remove a IBinder from SpeakEasy. */
  public static class Remove {
    /** The key to remove. */
    public final String key;

    private static final String KEY_KEY = "sep_rm_key";

    public Remove(String key) {
      this.key = key;
    }

    @Override
    public String toString() {
      return String.format("Remove: {key: %s}", key);
    }

    /**
     * Encodes a remove request into a bundle.
     *
     * @param key the Key representing the IBinder to remove
     * @return a bundle representing the command.
     * @throws NullPointerException if key is null.
     */
    public static Bundle asBundle(String key) {
      Bundle b = new Bundle();
      b.putInt(TYPE_KEY, REMOVE_TYPE);
      b.putString(KEY_KEY, checkNotNull(key));
      return b;
    }

    private static SpeakEasyProtocol fromBundle(Bundle b) {
      Remove r = new Remove(b.getString(KEY_KEY));
      if (null == r.key) {
        Log.w(TAG, String.format("'%s': not set", KEY_KEY));
        return null;
      }
      return new SpeakEasyProtocol(r);
    }
  }

  private static <T> T checkNotNull(T val) {
    if (null == val) {
      throw new NullPointerException();
    }
    return val;
  }

  /** Strips the custom subclass out of the ResultReceiver so you can ship between packages. */
  private static ResultReceiver marshableReceiver(ResultReceiver r) {
    if (r.getClass().equals(ResultReceiver.class)) {
      return r;
    }
    Parcel p = Parcel.obtain();
    try {
      r.writeToParcel(p, 0);
      p.setDataPosition(0);
      return ResultReceiver.CREATOR.createFromParcel(p);
    } finally {
      p.recycle();
    }
  }

  static {
    Method getIBinder = null;
    Method putIBinder = null;
    if (Build.VERSION.SDK_INT < 18) {
      try {
        getIBinder = Bundle.class.getMethod("getIBinder", String.class);
        putIBinder = Bundle.class.getMethod("putIBinder", String.class, IBinder.class);
      } catch (NoSuchMethodException nsme) {
        Log.e(TAG, "Cannot find methods for IBinders on bundle object", nsme);
        throw new RuntimeException(nsme);
      }
    }
    GET_IBINDER = getIBinder;
    PUT_IBINDER = putIBinder;
  }

  /** Gets an IBinder from a bundle safely. */
  private static IBinder getBinder(Bundle b, String key) {
    if (null != GET_IBINDER) {
      try {
        return (IBinder) GET_IBINDER.invoke(b, key);
      } catch (InvocationTargetException | IllegalAccessException ex) {
        throw new RuntimeException(ex);
      }
    }
    return b.getBinder(key);
  }

  /** Puts an IBinder in a bundle safely. */
  private static void putBinder(Bundle b, String key, IBinder val) {
    if (null != PUT_IBINDER) {
      try {
        PUT_IBINDER.invoke(b, key, val);
        return;
      } catch (InvocationTargetException | IllegalAccessException ex) {
        throw new RuntimeException(ex);
      }
    }
    b.putBinder(key, val);
  }
}