Javaのリフレクションを使ってprivateなクラスを活用しカスタムクラスを作る

# Javaのリフレクションを使ってprivateなクラスを活用しカスタムクラスを作る

privateなパッケージやクラスの中にあるメソッドやフィールドを使ってカスタムクラスを作ったときにハマった部分など備忘録

参考:[リフレクション](http://www.ne.jp/asahi/hishidama/home/tech/java/reflection.html#h2_field)

クラスのカスタムなどしたいとき、全てコピーするのは面倒なときなどにリフレクションをつかいます。JavaやAndroidのAPIがprivateな場合でも取得できるようなAPIが用意されています。

今回はTextViewに表示される文字列に`Linkify`を使ってリンクを作成したときのクリックイベントを取るためのカスタムです。

### [Linkify](http://developer.android.com/reference/android/text/util/Linkify.html)をリフレクションする

TextViewに入る文字列で、URLなどをリンクにする方法は二つある。

一つ目はxmlで`autoLink`を指定する

```xml
<TextView
    android:id="@+id/link"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:autoLink="web" />
```

もう一つはLinkifyを使って`addLink`する方法。

```java
TextView textview = (TextView) findViewById(R.id.link_textview);
textview.setText("http://google.com");
Linkify.addLinks(textview, Linkify.ALL);
```

`autoLink`も`Linkify`もリンクにできる種類は以下。

種類 | 内容
--- | ---
ALL|リンクにできるものは全てする
EMAIL_ADDRESS|メールアドレス
MAP_ADDRESS|住所
PHONE_NUMBER|電話番号
WEB_URLS|web

`Linkify`をカスタムしてOnClickイベントを各URLに仕込めるようにする。

さくっと`Linkify`クラスを継承してきて作ろうと思うと、packageがprivateなので、マスクと`addLinks`しか呼び出せない仕様になっている。( ˘ω˘)


![スクリーンショット 2014-11-18 18.49.18.png](https://qiita-image-store.s3.amazonaws.com/0/26883/6fe0644c-4381-5ed7-9067-3ffbfa51567f.png)

まるっと完コピしてもいいのですが、不要な処理は元のクラスにやってもらって、必要なインターフェースだけを提供するようにします。
今回はwebのURLをリンクに修正するようにします。

中を見てみると、必要そうなメソッドは`addLinks`と`addLinkMovementMethod`と`applyLink`と`gatherLink`と`pruneOverlaps`で、`sUrlMatchFilter`フィールドがあればURLをリンクにできます。

重要なのは二つで

* privateなjavaファイル内にあるクラスにアクセスする
* applyLinkの中にあるURLSpan

です。

* privateなjavaファイル内にあるクラスにアクセスする

Linkify.javaにはリンクとなる文字列を`LinkSpec`というクラスでもつのですが、
privateパッケージかつjavaファイル内にあるため普通にはアクセスできないのが面倒です。

```java:Linkify.java
public class Linkify {
  …
}
//どうやってこいつ取得すんねん…
class LinkSpec {
    String url;
    int start;
    int end;
}
```

方法は最初から言っているリフレクションを使うのですが、`LinkSpec`クラスを使用しているメソッドをリフレクションして、
その結果をObjectに渡し、フィールドにアクセスすると取得可能です。以下がprivateなクラスのfieldへアクセスする部分。

```java
//LinkSpecクラスからfieldを取得
for( Object link : links ) {
  Class clazz = link.getClass();
  String url = null;
  int start = 0;
  int end = 0;
  //privateなクラスからfieldを取得してリンクを適用
  for( Field field : clazz.getDeclaredFields() ) {
    //privateなfieldへのアクセスを可能にする
    field.setAccessible( true );

    try {
      if( field.getName().equals( "url" ) ) {
        url = (String) field.get( link );
      }
      else if( field.getName().equals( "start" ) ) {
        start = field.getInt( link );
      }
      else if( field.getName().equals( "end" ) ) {
        end = field.getInt( link );
      }
    }
    catch( IllegalAccessException e ) {
      e.printStackTrace();
    }
  }
  applyLink( url, start, end, text );
}
```

途中`setAccessible( true )`というところでアクセス権限を与えないと、エラーも無く結果が帰ってこないので注意。

そんなこんなで作成したのが以下のクラス。

```java:LinkifyProxy.java
/**
 * {@link android.text.util.Linkify}のリフレクションクラス
 */
public class LinkifyProxy extends Linkify {
  public static final MatchFilter sUrlMatchFilter = new MatchFilter() {
    public final boolean acceptMatch( CharSequence s, int start, int end ) {
      return start == 0 || s.charAt( start - 1 ) != '@';
    }
  };

  private static Linkify mLinkify = null;

  private LinkifyProxy() {}

  //Linkifyインスタンスを生成(singleton)
  private static Linkify getLinkify() {
    if( mLinkify == null ) {
      mLinkify = new Linkify();
      return mLinkify;
    }
    return mLinkify;
  }

  //リフレクションしたメソッドを実行
  private static Object invokeMethod( Linkify linkify, String methodName, Class[] clazz, Object... args ) {
    Method method;
    Object object = null;
    try {
      method = linkify.getClass().getDeclaredMethod( methodName, clazz );
      method.setAccessible( true );
      object = method.invoke( new ArrayList<Object>(), args );
    }
    catch( NoSuchMethodException e ) {
      e.printStackTrace();
    }
    catch( IllegalAccessException e ) {
      e.printStackTrace();
    }
    catch( InvocationTargetException e ) {
      e.printStackTrace();
    }
    return object;
  }

  //提供するインターフェースに応じてpublicにしたりしなかったり
  private static boolean addClickEventLinks( Spannable text, int mask ) {
    LogUtil.min();
    if( mask == 0 ) {
      return false;
    }

    URLSpan[] old = text.getSpans( 0, text.length(), URLSpan.class );

    for( int i = old.length - 1; i >= 0; i-- ) {
      text.removeSpan( old[i] );
    }

    ArrayList links = new ArrayList();
    if( (mask & WEB_URLS) != 0 ) {
      gatherLinks( links, text, Patterns.WEB_URL, new String[] {"http://", "https://", "rtsp://"}, sUrlMatchFilter, null );
    }

    pruneOverlaps( links );

    if( links.size() == 0 ) {
      return false;
    }

    //LinkSpecクラスからfieldを取得
    for( Object link : links ) {
      Class clazz = link.getClass();
      String url = null;
      int start = 0;
      int end = 0;
      //privateなクラスからfieldを取得してリンクを適用
      for( Field field : clazz.getDeclaredFields() ) {
        //privateなfieldへのアクセスを可能にする
        field.setAccessible( true );

        try {
          if( field.getName().equals( "url" ) ) {
            url = (String) field.get( link );
          }
          else if( field.getName().equals( "start" ) ) {
            start = field.getInt( link );
          }
          else if( field.getName().equals( "end" ) ) {
            end = field.getInt( link );
          }
        }
        catch( IllegalAccessException e ) {
          e.printStackTrace();
        }
      }
      applyLink( url, start, end, text );
    }
    return true;
  }

  //提供するインターフェースはaddLinksと同じ
  public static boolean addClickEventLinks( TextView text, int mask ) {
    if( mask == 0 ) {
      return false;
    }

    CharSequence t = text.getText();

    if( t instanceof Spannable ) {
      //変更
      if( addTrackingLinks( (Spannable) t, mask ) ) {
        addLinkMovementMethod( text );
        return true;
      }
      return false;
    }
    else {
      SpannableString s = SpannableString.valueOf( t );
        if( addTrackingLinks( s, mask ) ) {
          addLinkMovementMethod( text );
          text.setText( s );
          return true;
        }
      return false;
    }
  }

  //リフレクション
  private static void addLinkMovementMethod( TextView t ) {
    Linkify linkify  = getLinkify();
    Class[] clazz = { TextView.class };
    invokeMethod( linkify, "addLinkMovementMethod", clazz, t );
  }

  //ここが重要CustomしたURLSpanを渡すことでClickイベントをハンドリングできるようにする
  private static void applyLink( String url, int start, int end, Spannable text ) {
    CustomURLSpan customURLSpan = CustomURLSpan.createURL( url );
    text.setSpan( customURLSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE );
  }

  //リフレクション
  private static void gatherLinks( ArrayList links, Spannable s,
                                    Pattern pattern, String[] schemes,
                                     MatchFilter matchFilter, TransformFilter transformFilter ) {
    Linkify linkify = getLinkify();
    Class[] clazz = {ArrayList.class, Spannable.class,
                      Pattern.class, String[].class, MatchFilter.class, TransformFilter.class};
    invokeMethod( linkify, "gatherLinks", clazz, links, s, pattern, schemes, matchFilter, transformFilter );
  }

  //リフレクション
  private static void pruneOverlaps( ArrayList links ) {
    Linkify linkify = getLinkify();
    Class[] clazz = {ArrayList.class};
    invokeMethod( linkify, "pruneOverlaps", clazz, links );
  }
}
```

ここで重要なポイントの二つ目

* applyLinkの中にあるURLSpan

を確認します。

Linkifyクラスでは次のようになっています。

```java
private static final void applyLink(String url, int start, int end, Spannable text) {
  URLSpan span = new URLSpan(url);
  text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
```

なにやらこのURLSpanがURLの文字列を持ち、TextView内のどのURLかを区別するために`LinkSpec`クラスが持っていた
intの`start`と`end`を渡しているようです。では`URLSpan`を確認してみます。

### [URLSpan](http://developer.android.com/reference/android/text/style/URLSpan.html)をカスタムする

```java:URLSpan.java
public class URLSpan extends ClickableSpan implements ParcelableSpan {

  private final String mURL;

  public URLSpan(String url) {
    mURL = url;
  }

  public URLSpan(Parcel src) {
    mURL = src.readString();
  }

  public int getSpanTypeId() {
    return TextUtils.URL_SPAN;
  }

  public int describeContents() {
    return 0;
  }

  public void writeToParcel(Parcel dest, int flags) {
    dest.writeString(mURL);
  }

  public String getURL() {
    return mURL;
  }

  @Override
  public void onClick(View widget) {
    Uri uri = Uri.parse(getURL());
    Context context = widget.getContext();
    Intent intent = new Intent(Intent.ACTION_VIEW, uri);
    intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName());
    context.startActivity(intent);
  }
}
```

確認してみると、`onClick`イベントをオーバーライドしているので、
このクラスを継承して`onClick`をオーバーライドすればClickイベント時に自由な処理を行えそうです。

よって以下のCustomクラスを作成しました。


```java:CustomURLSpan.java
/**
 * URLをクリックしたときにイベントを変更できる{@link android.text.style.URLSpan}カスタムクラス
 */
public class CustomURLSpan extends URLSpan {

  private String mURL;

  //applyLinkがstaticなのでついでにFactoryMethodパターンへ変更
  public static CustomURLSpan createURL( String url ) {
    return new CustomURLSpan( url );
  }

  private CustomURLSpan( String url ) {
    super( url );
    mURL = url;
  }

  @Override
  public void onClick( View widget ) {
    super.onClick( widget );
    Toast toast = Toast.makeText( widget.getContext(), mURL, Toast.LENGTH_SHORT );
    toast.show();
  }
}
```

これでクリックしたらToastが出力されるようになります。あとはお好みで編集してください。

リンクが複数ある場合に、どのリンクを押したかをわかるようにするなどもいいかもしれません。