visible true

技術的なメモを書く

kaptで発生するエラーを回避するworkaround (JSR 269ライブラリ作者向け)

KotlinでJSR 269のライブラリを使う場合以下の様にkaptを使って設定するわけですが不安定な動きをする場合があります。

kapt {
    generateStubs = true
}
dependencies {
  compile 'com.github.sys1yagi.fragment-creator:library:0.6.0'
  kapt 'com.github.sys1yagi.fragment-creator:processor:0.6.0'
}

次のようなエラーが出たり、ファイルが生成されたりされなかったり。

:app:compileDebugKotlin UP-TO-DATE
:app:compileDebugJavaWithJavac FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugJavaWithJavac'.
> java.lang.NullPointerException

ちょうどJSR 269を使ったライブラリを2つ作っていたので原因を探りつつ回避する方法がないか調べていたらworkaroundを見つけたのでメモします。

annotation processor内で起こっている事

NullPointerExceptionが起こっているStackTraceに従って該当箇所を見てみると以下の様な処理をしていました。

//ライブラリが提供するアノテーションを取り出す
Simplify simplify = element.getAnnotation(Simplify.class); 
// ここでNullPointerException
String actionName = simplify.value(); 

ここではコードに付与したSimplifyアノテーションのパラメータ取り出してコード生成に利用しようとしています。elementはRoundEnvironment.getElementsAnnotatedWith(Simplify.class)で取り出しているので必ずSimplifyアノテーションを持っているはずなのですがnullが返ってきてエラーとなっているわけです。

なぜnullに?

Better Annotation Processing: Supporting Stubs in kapt | Kotlin BlogSource-retained annotationsの部分を読むとRetentionPolicy.SOURCEアノテーションはgenerateStubsでは直接.classを生成するので消失するよと書いています。このケースは生成したコードにRetentionPolicy.SOURCEアノテーションを付与していた場合消失するという事だと思うのでannotation processorで処理する時には関係ないように思うのですがどうもannotation processorに渡ってくるものもkt->classを経由してRetentionPolicy.SOURCEアノテーションが消失しているぽいです(にもかかわらずRoundEnvironment.getElementsAnnotatedWith()で取り出せるのが謎です)。

しかも初回ビルド時はアノテーションが保持されている状態で渡ってきて、二回目以降は消失するという挙動をするので「不安定だ」という印象を持ってしまうのだと思います(実際どういう風になっているかはkotlin-pluginの実装などを調べてみないとわかりませんが)。

//一回目 ちゃんと処理できる
@Simplify("polling")
public final class PollingAlarmProcessor implements AlarmProcessor {

//二回目以降 書き換わっている
@KotlinClass(version={1, 0, 1}, abiVersion=32, data={"\035\025\tA\"A\003\002\031\005)\021\001B\001\006\003!\tQ\001A\003\002\031\005)\001!B\001\r\003\021\035A\002A\r\0021\003\t+!U\002\002\021\005)C\002B\006\t\0045\t\001DA\r\004\021\013i\021\001G\002\032\007!\035Q\"\001\r\005"}, strings={"Lcom/sys1yagi/longeststreakandroid/alarm/PollingAlarmProcessor;", "Lcom/sys1yagi/android/alarmmanagersimplify/AlarmProcessor;", "()V", "process", "", "context", "Landroid/content/Context;", "intent", "Landroid/content/Intent;"})
public final class PollingAlarmProcessor implements AlarmProcessor {

ライブラリ側の対処方法

realm-javaがこの問題に既に対応していました。 Support KotlinLang · Issue #509 · realm/realm-java · GitHub

以下のようにライブラリ側でアノテーションに定義している@Retention(RetentionPolicy.SOURCE)@Retention(RetentionPolicy.CLASS)にすれば問題がでなくなります。

// workaround for kapt
// @Retention(RetentionPolicy.SOURCE)
@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
public @interface FragmentCreator {
}

やったね。

利用側の対処方法

別の対処法としてはアノテーションを書くファイルを.javaにする事です。これでつねに.javaがannotation processorで処理されるので安全です。とは言えこれだとKotlinにした意味がなくなるのでできるだけ避けたい所です。利用しているJSR 269のライブラリにPull Requestを出すといいんじゃないでしょうか。

enjoy

@Retention(RetentionPolicy.SOURCE)@Retention(RetentionPolicy.CLASS)の違いは.classにアノテーションが残るかどうかなのでまぁ問題ないと思います。Runtimeには乗らないので精々数百byteくらいclassファイルが大きくなる程度じゃないかなと思います。。

Androidでスクロールを含む画面全体のスクリーンショットを撮る

Android端末でスクリーンショットが撮れるようになって久しいですが、スクロールを含む画面全体のスクリーンショットについてはサポートされていません。デザインの全体を俯瞰してレビューする際などにはスクロールを含む画面全体のスクリーンショットがあると助かります。

PGSSoft/scrollscreenshotを利用すると比較的カンタンにスクロールを含む画面全体のスクリーンショットを撮れます。

セットアップ

リポジトリをcloneすれば利用できます。

git clone https://github.com/PGSSoft/scrollscreenshot.git

スクロールしながらスクリーンショットを撮る

scrollscreenshotはjarで提供されています。javaコマンドでスクリーンショットを撮るクラスを実行すれば良いのですがその前にいくつか準備が必要です。

環境変数ANDROID_SDK_HOMEを設定する

ANDROID_SDK_HOMEが設定されてない場合は.bash_profileなどに追加してください。実行時引数として渡すこともできますが面倒だと思うので環境変数を設定しておくと良いでしょう。

export ANDROID_SDK_HOME=/usr/local/opt/android-sdk

デバイスのデジタイザの入力番号を確認する

scrollscreenshotはadbでデバイスに接続し、デジタイザに対して直接タッチイベントを送信してスクロールをします。以下のコマンドを使って対象となるデバイスのデジタイザの入力番号を調べて下さい。

adb shell getevent -l

色々なイベントが流れてきます。最初は複数の入力番号が出てきますが端末を操作すると同じデジタイザで沢山イベントが発生するので判別ができます。以下のログだと/dev/input/event5の末尾の5が入力番号となります。

/dev/input/event5: EV_SYN       SYN_REPORT           00000000            
/dev/input/event5: EV_ABS       ABS_MT_POSITION_X    0000021f            
/dev/input/event5: EV_ABS       ABS_MT_POSITION_Y    0000051e            
/dev/input/event5: EV_SYN       SYN_MT_REPORT        00000000            
/dev/input/event5: EV_SYN       SYN_REPORT           00000000            
/dev/input/event5: EV_ABS       ABS_MT_POSITION_X    0000022c            

スクリーンショットを撮る

scrollscreenshotのバイナリにある場所に移動し、javaコマンドを使って実行します。-iオプションにデジタイザの入力番号を設定すれば正しくスクロールされ縦長のスクリーンショットが撮れるはずです。画像は実行したディレクトリの直下にout.pngという名前で保存されます。

cd scrollscreenshot/binaries
java -cp scrollscreenshot-latest.jar com.pgssoft.scrollscreenshot.ScrollScreenShot -i 5

こんな感じでスクリーンショットが撮れます(仕組み上ガタつく所が出ます)。

f:id:sys1yagi:20160127232235p:plain

注意点とオプション

scrollscreenshotはadb接続をしてスクロールをしながらスクリーンショットを撮って最後に結合するという仕組みで動いており画面上のスクロールの状態については関与できません。このためスクリーンショットを撮る画面毎にスクロール回数を調整する必要があります。スクロール回数(スクリーンショットの回数)は-cで指定できます。この他にもいくつかオプションがあります。

scrollscreenshotのオプションを次に示します*1

Usage: com.pgssoft.scrollscreenshot.ScrollScreenShot [options]
  Options:
    -c, --count
       スクロールの回数
       デフォルト: 5
    -v, --device
       複数の端末が接続されている場合Device IDを指定できます。特に指定がなければ最初のデバイスに接続します
    -d, --direction
       スワイプの方向を指定できます。topdown (default), leftright 
       デフォルト: topdown
    -h, --help
       ヘルプ
       デフォルト: false
    -e, --inertia
       コンテンツの慣性。スクロールに必要なピクセル数を指定できます
       デフォルト: 0
  * -i, --inputdevice
       デジタイザの入力番号。 /dev/input/eventNのN
       デフォルト: 1
    -n, --nameprefix
       出力ファイルのプレフィクス
       デフォルト: out
    -p, --pathsdk
       Android SDKのパス
    -s, --stitch
       画像の結合方法: full (つなぎ目がスムーズ), none (差分を気にせずそのまま結合する),
       separate (別々のファイルに分ける)
       デフォルト: full

enjoy

scrollscreenshotのソースを読むとjavaからAndroidDebugBridgeでadb接続してBufferedImageを使って結合などしていてとてもシンプルなので簡単に俺得スクリーンショットツールが作れるんじゃないかなと思います。タッチイベントも送出できるので特定動作を繰り返す仕組みとかできるんじゃないですかね。

*1:README.mdから抜粋し日本語化しました

fragment-creator 0.6.0 released

前回の記事(JSR 269の勉強がてらFragment生成と引数周りを楽にするfragment-creatorというライブラリを作った - visible true)は0.4.0を基にしており、その後いくつか書き方が変わったので差分を書いておきます。

本ライブラリはJitPackで配布しています。JitPackの設定をbuild.gradleに追加した上で以下をdependenciesに追加してください。

apt 'com.github.sys1yagi.fragment-creator:processor:0.6.0'
compile 'com.github.sys1yagi.fragment-creator:library:0.6.0'

Builderの作り方が変わった

Builder.newInstance()という命名はFragment#newInstance()を想起させてややこしいという意見を貰ったので改善しました。具体的にはnewBuilder()をCreatorに生やす形にしました。

before

MainFragment fragment = MainFragmentCreator.Builder
  .newInstance("keyword") // requiredな値はここで渡す
  .setUserId("userId")    // optionalな値はsetterで渡す
  .build();

after

MainFragment fragment = MainFragmentCreator
  .newBuilder("keyword") // requiredな値はここで渡す
  .setUserId("userId")    // optionalな値はsetterで渡す
  .build();

private fieldの利用

private fieldをサポートしました。setterを用意しておく必要があるのでご注意ください(リフレクションでsetしてもいいんですがそれだとコード生成している意味がないのでやってません)。

public MainFragment extends Fragment {
    @Args
    String keyword;
    @Args(require = false)
    private String userId;
    public String setUserId(String userId){
        this.userId = userId;
    }
}

Default値の設定

primitiveについてはDefault値の指定をサポートしました。引数がoptionalの場合利用できます。defaultStringとかdefaultIntとかダサいんですがアノテーションには定数しか指定できないので仕方がなさそうです。Class<? extends DefaultValueProvider>的なものを受け取ってゴニョゴニョしてもいいんですがprimitive typeの為にわざわざそういうクラス定義するのも大袈裟なので一旦defaultXXXを列挙する形にしました。今後は複雑な値のデフォルト値や引数を変換する仕組みなど入れようかなとか考えてます。

public MainFragment extends Fragment {
    @Args(require = false, defaultString = "unknown")
    String keyword;
    @Args(require = false, defaultInt = -1)
    int userId;
}

おわりに

JSR 269での生成周りのコード自身が結構単純作業でなかなか大変。

JSR 269の勉強がてらFragment生成と引数周りを楽にするfragment-creatorというライブラリを作った

aptしてますか。 正確にはPluggable Annotation Processing API(JSR 269)なので以降はJSR 269と書きます。

Dagger2やRealmなどJSR 269でコード生成を行っているライブラリ群の理解を深め、あわよくばcontributionするためJSR 269を勉強しつつチョットしたライブラリを作りることにしました。

完成したものが以下。

sys1yagi/fragment-creator - Java - GitHub

FragmentのnewInstanceメソッドとArgumentsの取扱いにまつわる部分を生成するライブラリです。

使い方

使い方はREADME.mdを見てもらえばわかるとは思いますが一応簡単に書いておきます。

以下のようにFragmentにアノテーションを付与するとMainFragmentCreatorというクラスが生成されます。

@FragmentCreator
public class MainFragment extends Fragment {
    @Args
    String keyword;
    @Args(require = false)
    String userId;
}

あとは使うだけ。

※0.6.0以降ではBuilder周りの使い方が変わっています。fragment-creator 0.6.0 releasedを参照してください。

MainFragment fragment = MainFragmentCreator.Builder
  .newInstance("keyword") // requiredな値はここで渡す
  .setUserId("userId")    // optionalな値はsetterで渡す
  .build();

FragmentのonCreate()でArgumentsの読み込みを行います。

@Args
String keyword;
@Args(require = false)
String userId;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  MainFragmentCreator.read(this);
  //keyword, userIdが初期化される
}

らくちんだ。

参考にしたもの

今後

とりあえず使えるようにはなったもののまだ機能が足りないのでvalidatorとかconverterとか設定できるようにしようかなと思います。あとKotlin化してkaptでハマった上で修正をKotlinに出せたらいいかなとか。

robolectric3でShadowクラスを作るメモ

robolectric3のドキュメント通りにShadowクラスを書いてもうまく動かず、結局robolectric自身のソースを読んで理解してめんどくさかったのでメモしておきます。

Shadowクラスを定義する

Shadowクラスは以下の手順で宣言します。

  • クラス宣言に@Implementsアノテーションを付与し、書き換え対象となるクラスを設定する
  • フィールドに@RealObjectアノテーションを付与した書き換え対象のクラスを宣言する。これは本当のオブジェクトの処理を呼び出したい場合に使います。

Shadowクラスはだいたい${application_id}.testtool.shadowパッケージに置いてます。本エントリではGsonBuilderの書き換えを行います。

import com.google.gson.GsonBuilder;

import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.RealObject;

@Implements(GsonBuilder.class)
public class ShadowGsonBuilder {
  @RealObject
  private GsonBuilder realObject;
  //色々
}

メソッドを書き換える

メソッドを書き換えるには@Implementationアノテーションを使います。

@Implementation
public GsonBuilder setDateFormat(String format) {
  return Shadow.directlyOn(realObject, GsonBuilder.class)
          .setDateFormat(format.replace("Z", "X"));
}

ここではGsonBuilderのsetDateFormat(String)を書き換えてます。Gson 2.4以下ではタイムゾーンZが利用できずISO8601のパース時にコケます*1。代わりにXを使わないといけないのですがXはJava7以降でサポートされたものでAndroid上では動作しません*2。という事でsetDateFormat(String)の引数をrobolectricでのテスト時だけ書き換えるようにしてます。

return realObject.setDateFormat(format.replace("Z", "X"))と書くと無限ループしてStackOverflowErrorになります。そこでShadow.directlyOn()を使って本物のクラスのメソッドを呼び出します。

コンストラクタを書き換える

コンストラクタを書き換えたいケースはほぼないと思いますがもし書き換えるなら以下のようになります*3。ポイントは__constructor__というメソッド名とShadow.invokeConstructor()でしょう。

@Implements(Paint.class)
public class ShadowPaint {
  //省略
  @RealObject Paint paint;

  public void __constructor__(int flags) {
    //省略
    Shadow.invokeConstructor(Paint.class, paint, ReflectionHelpers.ClassParameter.from(int.class, flags));
  }

  //省略
}

カスタムRobolectricTestRunnerを作りShadowクラスを登録する

Shadowクラスを作ったら次にカスタムRobolectricTestRunnerを作ってShadowクラスの登録とマッピングを行います。Shadowクラスを追加する度に更新しないといけないので面倒です。

public class MyRobolectricTestRunner extends RobolectricTestRunner {
  public MyRobolectricTestRunner(Class<?> testClass) throws InitializationError {
    super(testClass);
  }
  @Override
  protected ShadowMap createShadowMap() {
    return super.createShadowMap().newBuilder()
            //Shadowクラスを登録する
            .addShadowClass(ShadowGsonBuilder.class)
            .build();
  }
  public InstrumentationConfiguration createClassLoaderConfig() {
    return InstrumentationConfiguration.newBuilder()
            //クラスロード時に書き換え対象となるクラスのFQCNを登録する
            .addInstrumentedClass(GsonBuilder.class.getName())
            .build();
  }
}

テストでカスタムRunnerを使い、Configで利用するShadowを宣言する

Shadowクラスをテストで利用するにはShadowクラスのマッピングを行うRunnerを@RunWithで指定し、さらに@Configアノテーションで利用するShadowクラスを宣言します。

@RunWith(MyRobolectricTestRunner.class)
@Config(shadows = {ShadowGsonBuilder.class})
public class FooTest {
  //...
}

これでこのテスト内ではGsonBuilderが書き換えられます。やったね。

Java標準ライブラリ等は書き換えられないので注意

https://github.com/robolectric/robolectric/blob/master/robolectric/src/main/java/org/robolectric/internal/bytecode/InstrumentationConfiguration.java#L161などに書き換えを除外するパッケージやクラスが定義されてます。書き換わらないぞ!?という時はチェックするとよさそうです。もしかしたらこの辺も設定次第でいけるかもしれません。

とりあえずShadowクラスは最後の手段だと考えておいた方がいいでしょう。ほとんどの場合はrobolectric自身が提供するShadowクラスの利用やpowermockでの書き換えで十分対応できると思います。

Charles & Android Emulator 2015

Android EmulatorでCharles使う情報がだんだん古くなって現代に即してないので適当にメモする。

イマドキのAndroid Emulatorは-http-proxy

以前はAccess Point Namesでproxy設定していたけど5.0辺りからこの設定が利かなくなっている。emulator起動時の引数に設定を渡さないといけなくなった。

emulator -avd $avd_name -http-proxy http://$your_localhost_ip:8888

SSLサーバ証明書を入れる

プロキシ設定をして起動したあとSSLサーバ証明書をインストールする。emulatorのブラウザで以下のURLにアクセスすればOK。

http://charlesproxy.com/getssl/

avd-pecoなら楽ちん

avd-pecoでパラメータ受け取れるようにしたのでemulator起動もプロキシ設定も楽になった。

github.com

こんな感じ。

avd_peco "-http-proxy http://$your_localhost_ip:8888"

Android 6.0 の Runtime Permissions (M Permissions) に対応するためのアクティビティ図

M Permissionsをやっつけようという事でAPIやドキュメントを眺めたらそれなりにシンプルだなと思っていたけど実際手を出してみるとすごくややこしかったのでアクティビティ図にした。気をつけるべき点を後述する。

f:id:sys1yagi:20151107172329p:plain

targetSdkVersionの違いによる挙動

アプリのtargetSdkVersionによってインストール時の挙動が異なる点についてはドキュメントに書かれている。

Requesting Permissions at Run Time | Android Developers

If the device is running Android 5.1 or lower, or your app's target SDK is 22 or lower: If you list a dangerous permission in your manifest, the user has to grant the permission when they install the app; if they do not grant the permission, the system does not install the app at all.

端末が5.1以下かtargetSdkVersionが22以下ならインストール時に全権限の確認を行う。拒否された場合はインストールできない。

If the device is running Android 6.0 or higher, and your app's target SDK is 23 or higher: The app has to list the permissions in the manifest, and it must request each dangerous permission it needs while the app is running. The user can grant or deny each permission, and the app can continue to run with limited capabilities even if the user denies a permission request.

端末が6.0以上で且つtargetSdkVersionが23以上ならアプリの実行時に権限の許可を貰わなければならない。

Note: This lesson describes how you implement permissions requests on apps that target API level 23 or higher, and are running on a device that's running Android 6.0 (API level 23) or higher. If the device or the app's targetSdkVersion is 22 or lower, the system prompts the user to grant all dangerous permissions when they install or update the app.

しかしここには端末が6.0以上で且つtargetSdkVersionが22以下でインストール後に権限をOFFにしたケースが書かれていない。このケースの場合ContextCompatPermissionCheckercheckSelfPermission()の挙動が異なる。

  • ContextCompat : 常にGRANTED
  • PermissionChecker : GRANTED or PERMISSION_DENIED_APP_OP

このためパーミッションのチェックには常にPermissionCheckerを使っておいた方がよさそう。

Never ask againの対応

requestPermissions()を呼び出した時、「今後は確認しない」というチェックボックスが表示される。これにチェックがつくとrequestPermissions()を呼び出しても許可のダイアログが出ずに即座にonRequestPermissionsResult()にDENIEDが返るようになる。「今後は確認しない」がチェックされている場合はアプリ設定に飛んでユーザ自身にチェックボックスをONにしてもらうしかない。

「今後は確認しない」がチェックされているかどうかを判定するにはPermissionChecker.checkSelfPermission()shouldShowRequestPermissionRationale()を使う。

@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions,
    int[] grantResults) {
  super.onRequestPermissionsResult(requestCode, permissions, grantResults);

  switch (requestCode) {
    case REQUEST_CODE:
      if (!verifyPermissions(grantResults)) {
        if (shouldShowRequestPermissionRationale(REQUIRE_PERMISSION)) {
          // show error
        } else {
          // Never ask again.
          // show app settings guide.
        }
      } else {
        // do something.
      }
  }
}

onRequestPermissionsResult()のタイミングでshouldShowRequestPermissionRationale()を実行した時「今後確認しない」がチェックされていなければ必ずtrueになる。この値がfalseだった時はアプリ設定でONにしてもらう旨を説明する表示を行う*1。アプリ設定画面を起動するためのIntentについては後述する。

ちなみにtargetSdkVersionが22以下の場合は「今後は確認しない」のチェックボックスは出ないのでこのフローは気にしなくてよい。

実装する必要のあるアクション

アクティビティ図のアクションのうち[]で囲んだものは独自に実装する必要のあるアクションである。どういうものを実装すればいいか簡潔に説明する。

[show rationale]

一度権限を拒否された場合に表示する。これはユーザに要求する権限によって何をするか、何が出来るようになるかを説明するためのもの。ほとんどの場合ここで権限について説明しその後許可ダイアログを出すフローに遷移する。アクティビティ図でもOK,Cancelの選択を出す事を前提としている。

[show error]

権限の許可を拒否された場合に表示するもの。権限を拒否した結果機能が使えない事を説明する。Toast, SnackBar, ダイアログ、画面のどれでもいいと思う。

[check result]

onRequestPermissionsResult()の処理。ここが仕様によって変わる事はなさそう。以下のメソッドをどこかに定義しておいて使えばよいと思う。

public boolean verifyPermissions(@NonNull int... grantResults) {
  for (int result : grantResults) {
    if (result != PackageManager.PERMISSION_GRANTED) {
      return false;
    }
  }
  return true;
}

[show app settings guide]

「今後確認しない」をチェックしている場合、アプリの設定画面に飛んでもらい権限を手動でONにしてもらうしかない。以下のUIを見ればわかる通り単純に画面を起動するだけでは操作をしてもらえないだろう。どういった手順で操作するかを説明する画面をこのアクションで表示した上で設定画面を開くべきである。

[open app settings]

アプリの設定画面は以下のIntentで起動できる。残念ながら権限画面を直接起動するIntentは無いらしい。

Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
Uri uri = Uri.fromParts("package", getPackageName(), null); //Fragmentの場合はgetContext().getPackageName()
intent.setData(uri);
startActivity(intent);

startActivityForResult()を使って復帰時に再度権限チェックの処理を走らせてもよいと思うがそこはアプリの設計次第になる。

さいごに

アクティビティ図に合わせて実装すればうまく動作するものが作れると思う。targetSdkVersion 22のフローで実装した場合、将来23になった時に切り替え忘れて事故ると思うので、予め23のフローも実装しておいてtargetSdkVersionを23にするというissueでも作ってTODOリストに切り替えを忘れない旨を書いておくとよいと思う。

独自に実装する必要のあるアクションについてはアプリの仕様次第でかなり揺れると思うので参考程度に見ておいてください。

*1:いきなり設定画面に飛ばしてもいいとは思うがおそらく手順の説明なしでは適切にONにするのは難しいとおもう