sumioの技術メモ

Androidについての記事が多くなると思います。

UIAutomator 2.0の日本語入力を試してみた

少し前に、UIAutomator 2.0が、Android Testing Support Libraryに入るようになりました。

https://plus.google.com/+AndroidDevelopers/posts/WCWANrPkRxg

既にセットアップ方法や、APIリファレンスも公開されています。

早速APIリファレンスを眺めてみたところ、UiObject.setText(String)の説明)に、以下のような注記があるのを見付けました。

Improvements: Post API Level 19 (KitKat release), the underlying implementation is updated to a dedicated set text accessibility action, and it also now supports Unicode.

どうも、Lollipop以降であれば、UiObject.setText(String)で日本語(Unicode)が入力できるようになっているようです。

ちょうど、Googleが公開しているサンプルが、文字を入力するものだったので、 ChangeTextBehaviorTest.javaの以下の部分を日本語に変更して動作を確認してみました。

    private static final String STRING_TO_BE_TYPED = "こんにちは";

その結果、手持ちのNexus7 (Android 5.0.2)で、普通に日本語が入力できることが確認できました。

これでUiautomator Unicode Input Helperの助けを借りずとも、日本語が入力できそうです(KitKat以前では従前通り助けが必要です)。

第9回potatotipsでRobolectricでPowerMockを使う方法を発表しました #potatotips

2014/9/24(水)に開催された【第9回】potatotips(iOS/Android開発Tips共有会)で発表してきました。以下が発表資料です。

Android単体テストフレームワークRobolectricPowerMockを使う方法(開発環境にAndroid Studioを使う場合)を紹介する内容です。

当日の発表は、持ち時間が5分ということもあり、ほとんど結論だけの内容にしたのですが、このエントリーでは、この結論に至った経緯について少し補足したいと思います。

JDKのバージョンについて

RobolectricとPowerMockを共存させるために利用しているPowerMockAgentの問題で、JDK1.7でテストを実行しようとすると、以下のような例外が発生し、実行に失敗してしまいます。

java.lang.IllegalStateException: Unable to load Java agent; please add lib/tools.jar from your JDK to the classpath

この問題は、PowerMockのissueに登録されていて、Mavenを使う場合の回避策は書かれているのですが、Gradle/Android Studioで解決する方法がわかりませんでした。 具体的には、テスト実行時に-javaagentオプションに加えて-XX:-UseSplitVerifierを指定すればうまくいきそうなのですが、自分の環境では解決しませんでした。

この点を解決する方法が見付かれば、JDK1.7以降でも動作させることができると思います。

robolectric-gradle-pluginのバージョンについて

発表時点におけるrobolectric-gradle-pluginのリリースバージョンは0.12.0だったのですが、0.12.0ではテスト実行時のオプション引数を指定するためのjvmArgs機能が実装されていませんでした。

PowerMockAgentの方法を使ってテストを実行するときは、テスト実行時に-javaagentオプションを使って、powermock-module-javaagent-1.5.5.jarへのパスを指定する必要があります。

そのため、jvmArgs機能が実装されているmasterブランチの先端(資料作成当時のリビジョンはこちら)を自分でビルドする方法を紹介したのですが、今確認したら、発表当日に0.13.0がリリースされていたようです。

0.13.0にはjvmArgsを指定できる機能も含まれているようなので、こちらを利用すれば、わざわざ自分でビルドする必要はなさそうです。まだ0.13.0での動作確認はできていないので、近々確認してみようと思います。

RobolectricのConfigファイルの配置について

Robolectricでテストを実行するには、テスト対象アプリのAndroidManifest.xmlの場所をorg.robolectric.Config.propertiesファイルに指定する必要があります。 このファイルを置くディレクトリについて、発表ではsrc/test/resと説明しましたが、少なくともrobolectric-gradle-pluginの0.12.0ではsrc/test/resourcesが正しい置き場所でした。

その後のどこかでsrc/test/res配下を見るように仕様変更したようなのですが、意図したものなのかバグなのか分かっていません。今後またsrc/test/resから変更されるかも知れませんので、うまくテスト対象アプリが見付けられていないようなら、このファイルが正しく読み込まれているかどうか、以下の方法で確認してみると良いと思います。

  • gradle clean testDebugClassesを実行する。
  • build/test-classesディレクトリにorg.robolectric.Config.propertiesファイルが存在するかどうかを確認する。存在していれば問題なし。

Android Studioで実行できるようにするための設定について

Android StudioでRobolectricのテストを実行する方法については、id:STAR_ZERO さんによる以下の記事が非常に参考になりました。

この記事に書かれている内容をrobolectric-gradle-pluginの最新版に対応させ、PowerMock対応のための実行時オプションを追加したものが、今回の発表のベースになっています。

Javaエージェント方式 vs JUnit Rule方式

RobolectricとPowerMockの併存が難しい根本的な理由は、JUnit4の@RunWithアノテーションで指定できるテストランナーが1クラスに限定されているからです(発表スライドp.8参照)。一方、PowerMockには@RunWithを使わない方法として、以下の2通りが用意されています。

このどちらの方法を採用しても、@RunWithの問題を解決できるはずですなのですが、自分の環境では、JUnit Rule方式ではXStream周りで例外が出て起動に失敗してしまい、うまく動作しませんでした。

そのような経緯で、Javaエージェント方式を採用したのですが、Javaエージェント方式は公式ドキュメントにexperimentalと書かれていることもあり、もし例外発生を回避できる方法が見付かるなら、JUnit Rule方式がより望ましいと思います。

JUnit Rule方式を使うことができるようになれば、JDK1.6縛りの問題も解決されますし、-javaagentオプションをテスト実行時に指定する必要がなくなるため、build.gradleもかなりシンプルになるはずです。 もし解決方法をご存知の方がいらっしゃいましたら、コメントいただけると有り難いです。

さいごに

potatotipsに参加するのは今回が2回目だったのですが、前回と同様、どの発表も自分にとっては知らないことばかりで、「今度機会があったら使ってみよう」と思えるTipsが揃っていました。自分の発表もそのように感じて下さった方がいらっしゃったとしたら大変嬉しいです。

発表後の懇親会では、Androidアプリ開発にまつわる色々な視点の話を伺うことができ、とても参考になると共に、楽しい時間を過ごすことができました。自分では思い至らないような視点を持っている方が多く、自分ももっと視野を広げねば、と思った1日でした。また何かネタを見つけて発表したいと思います!

主催してくださった @ninjinkun さんを始めとするFablic,inc.の皆さん、運営サイドの皆さん、どうもありがとうございました。

android.test.casual2で発表してきました #androidcasualtest

2014/4/3(木)に開催された android.test.casual #2 でLTをしました。以下が発表資料です。

「もしもの時にも安心なuiautomatorのwatcher機能」 というタイトルで、 uiautomatorフレームワークが提供しているUiWatcher APIの振舞いを紹介しました。

今回ご紹介したUiWatcherというのは、

  • テスト対象アプリの強制終了ダイアログ表示
  • テスト対象アプリのANRダイアログ表示

のような、テストシナリオの進行とは無関係に発生する事象に対して、 どのように操作したいかを登録できる機能です。

uiautomatorが持つ、かなり特徴的な機能だと思うのですが、 公式ドキュメントにはほとんど記載がなく、 あまり知られていなさそうな雰囲気がしたので、今回発表してみました。

詳細は上記発表スライドを見ていただきたいのですが、 うまく使うと非常に便利だと思いますので、是非お試しください。

今回も、色々な立場の方の話を聴くことができて、とても励みになりました。 また次回も参加したいですね!

第1回Android Test Casual Talks でuiautomatorの話をしました #androidtest

12/13(金)に開催された Android Test Casual Talks #1 でLTをしました。以下が発表資料です。

uiautomatorを使うときのひと工夫」というタイトルで、 拙作のUiautomator Unicode Input Helperの紹介をベースに、 日本語環境でuiautomatorを快適に利用する工夫について発表しました。

勉強会では初めての発表だったということもあり、 かなりドキドキしていたのですが、皆さんに興味を持って聴いていただけたようで、 ほっとしています。

LTの補足

発表の中で、 UiObject.clearTextField() の実装を日本語ロケール向けにローカライズを行った、 というお話もさせていただきました。 それについて、帰宅後に少し調べてみたところ、 意外と簡単に国際化対応できそうな事に気付きましたので、 このブログで補足させてください。

このような対応が必要となる原因は、 UiObject.clearTextField()実装で、 以下のように"Select all"がハードコーディングされているからでした。

public void clearTextField() throws UiObjectNotFoundException {
    // (省略)
    getInteractionController().longTapNoSync(rect.left + 20, rect.centerY());
    // check if the edit menu is open
    UiObject selectAll 
        = new UiObject(new UiSelector().descriptionContains("Select all"));
    if(selectAll.waitForExists(50))
        selectAll.click();
    // wait for the selection
    SystemClock.sleep(250);
    // delete it
    getInteractionController().sendKey(KeyEvent.KEYCODE_DEL, 0);
}

"Select all"を表すリソースIDは R.string.selectAll なので、通常のAndroidアプリであれば"Select all"context.getString(android.R.string.selectAll) (contextContextインスタンス) に置き換えれば自然に国際化できるのですが、 uiautomatorのテストコードからはContextを取得する手段が無いため、 getString()できないだろうと思い込んでいました。

ところが、その思い込みは間違いで、システムリソース (android.R.から始まるリソース)に限り Resources.getSystem() を使えば、Context無しにリソースにアクセスできることが分かりました*1。 恥ずかしながら、このようなAPIが有ることを今まで知りませんでした。

手持ちの以下の2つの端末で確認してみた感じでは、 uiautomatorから上記を呼び出すと、端末の言語設定に応じて、 適切な文字列を返してくれているようです。

ですので、原理的には、以下のように書き換えれば、 端末の言語設定にかかわらず、clearTextField()が動作するはずです。 AOSPへのバグ報告は別途行いたいと思います。

public void clearTextField() throws UiObjectNotFoundException {
    // (省略)
    String selectAllDesc 
        = Resources.getSystem().getString(android.R.string.selectAll);
    UiObject selectAll 
        = new UiObject(new UiSelector().descriptionContains(selectAllDesc));
    // (省略)
}

各自で回避する場合の多言語対応版は以下のようになります。

public void myClearTextField(UiObject editText)
         throws UiObjectNotFoundException {
    editText.longClickTopLeft();
    // "Select all"のリソース文字列を取得
    String selectAllDesc 
            = Resources.getSystem().getString(android.R.string.selectAll);
    UiObject selectAll 
            = new UiObject(new UiSelector().descriptionContains(selectAllDesc));
    if (selectAll.waitForExists(50))
        selectAll.click();
    SystemClock.sleep(250);
    getUiDevice().pressDelete();
}

さいごに

他の皆さんのLTも、どれも新しい発見があり、とても興味深く聴かせていただきました。 これからも、このような勉強会で引き続き情報交換していきたいです。

今回の勉強会を主催し、LTに誘って下さった@hotchemiさんをはじめとする運営の皆さん、 コメント下さった参加者の皆さん、本当にありがとうございました。

*1:実は、「Contextを取得できない」というのも勘違いの可能性が高いです。Android4.3からはUiAutomatorTestCaseがInstrumentationTestCaseを継承するようになっていました。ということは、getInstrumentation().getContext()が呼び出せるかもしれません。今度調べてみようと思います。

KitKatの文字コード変換バグを回避する

はじめに

先日、手許のNeuxs7にもKitKatが降ってきました。
早速アップグレードしてみたところ、「Uiautomatorで日本語を入力する」で紹介した、拙作のuiautomator-unicode-input-helper(以後、UUIHと略します)が全く動作しなくなってしまいました…。

原因を調べたところ、KitKatには、Java文字コード変換を担っているCharsetEncoderにバグがあることが分かりました。一応の回避策も判明したので、書き留めておこうと思います。

事象

KitKatでUUIHを使うときに、以下のように、自作のヘルパーライブラリを使って日本語を入力しようとすると、文字列によって、全部入力できたり、一部だけ入力できたり、全く入力できなかったりします。

UiObject editText = ...;
editText.setText(Utf7ImeHelper.e("こんにちは!UiAutomatorで入力しています。"));
// うまく入力できない!

ここで、Utf7ImeHelper.e(String)の実装は以下のような単純なものです。

private static final Charset CHARSET_MODIFIED_UTF7 
        = new CharsetProvider().charsetForName("X-MODIFIED-UTF-7");

public static String e(String text) {
    byte[] encodedByte = text.getBytes(CHARSET_MODIFIED_UTF7);
    return new String(encodedByte, Charset.forName("US-ASCII"));
}

ところが、手動でModified UTF-7エンコードしたものを指定すると、全く問題なく入力できます。

UiObject editText = ...;
// 「こんにちは!UiAutomatorで入力しています。」を修正UTF-7でエンコードしたもの
String e = "&MFMwkzBrMGEwb,8B-UiAutomator&MGdRZVKbMFcwZjBEMH4wWTAC-";
editText.setText(e);
// 問題なく入力できる!

そこで、この文字列とUtf7ImeHelper.e(...)の変換結果と比べてみると、text.getBytes(CHARSET_MODIFIED_UTF7)した時点で、既に末尾の"-"が欠けていることが判明しました。

次に、似た事象を検索しみたところ、こちらの記事ではSJISで似たような事象が起きていることがわかりました。これはどうもKitKat文字コード変換まわりに問題がありそうです。

解析

String.getBytes(Charset)が内部で呼び出していると思われる、CharsetEncoder.encode(CharBuffer)ソースコードをあたってみたところ、Android4.4では以下のようになっていました。

public final ByteBuffer encode(CharBuffer in) throws CharacterCodingException {
    ...
    reset();

    while (in.hasRemaining()) {
        CoderResult result = encode(in, out, true);
        if (result == CoderResult.UNDERFLOW) {
            break;
        } else if (result == CoderResult.OVERFLOW) {
            out = allocateMore(out);
            continue;
        } else {
            checkCoderResult(result);
        }

	// resultがUNDERFLOWかOVERFLOWである限り、ここに到達することはない!

        result = flush(out);
        ....
    }
    ....
}

このコードから呼び出されているCharsetEncoder.encode(CharBuffer in, ByteBuffer out, boolean endOfInput)
が、文字コード変換の要となるメソッドです。APIリファレンスによると、このメソッドを呼び出す時には、以下のプロトコルに従う必要があります。

  • まずreset()を呼び出す
  • encode(CharBuffer in, ByteBuffer out, boolean endOfInput)を呼び出してエンコードする。
    • 一度に全入力を渡さず、何回かに分けて呼び出す場合には、一番最後の呼び出しのときだけ、第3引数endOfInputtrueにして呼び出す。そうでない場合には第3引数にはfalseを指定する。
    • 1回で全入力を渡せる場合は、いきなり第3引数にtrueを指定して呼び出せばよい。
    • 入力が壊れていない場合、encode(CharBuffer, ByteBuffer, boolean)の結果は、CoderResult.UNDERFLOWか、CoderResult.OVERFLOWのいずれかになる。OVERFLOWの場合は、出力バッファのサイズが足りていないので、出力バッファを拡張して、encode(CharBuffer, ByteBuffer, boolean)を呼び直さなければならない。UNDERFLOWは正常終了を表す。
  • 変換がおわったらflush(ByteBuffer)を呼ぶ

ところが、上記コードを見ると、encode(CharBuffer, ByteBuffer, boolean)の結果がOVERFLOWとUNDERFLOWしか取らない場合では、(必ずbreakcontinueに到達するので)絶対にflush(ByteBuffer)が呼び出されることはありません。

自分がModified UTF-7への変換に使っているライブラリjutf7は、flush(ByteBuffer)呼び出し時に'-'を出力する実装になっていたので、末尾の'-'が欠ける結果となっていたのでした。

回避策

以上をふまえると、きちんと最後にflush(ByteBuffer)を呼び出してあげれば良いことがわかりますので、AOSPのソースコードから、バグの無いところを抜粋して、以下のように書き換えてみたところ、KitKatでも動作するようになりました。

/*
 * このコードは、
 *
 * https://android.googlesource.com/platform/libcore/+/master
 *
 * の以下のファイルから一部抜粋し、改変したものです。
 * - libdvm/src/main/java/java/lang/String.java
 * - luni/src/main/java/java/nio/charset/CharsetEncoder.java
 *
 * この部分のライセンスはApache License, Version 2.0とします。
 */

/**
  * Modified UTF-7形式にエンコードした文字列に変換する。
  */
public static String e(String text) {
    byte[] encoded = getBytes(text, CHARSET_MODIFIED_UTF7);
    return new String(encoded, Charset.forName("US-ASCII"));
}

/**
  * String.getBytes(Charset)を、
  * 自作の{@link #encode(CharBuffer, CharsetEncoder)}
  * を呼び出すように改造したもの。
  */
public static byte[] getBytes(String input, Charset charset) {
    CharBuffer chars = CharBuffer.wrap(input.toCharArray());
    CharsetEncoder encoder = charset.newEncoder()
            .onMalformedInput(CodingErrorAction.REPLACE)
            .onUnmappableCharacter(CodingErrorAction.REPLACE);
    ByteBuffer buffer;
    buffer = encode(chars.asReadOnlyBuffer(), encoder);
    byte[] bytes = new byte[buffer.limit()];
    buffer.get(bytes);
    return bytes;
}

/**
  * CharsetEncoder.encode(CharBuffer) のバグ修正版。
  * ただし、{@link #getBytes(String, Charset)}
  * からしか呼び出されない前提で、処理を簡略化している。
  */
private static ByteBuffer encode(CharBuffer in, CharsetEncoder encoder) {
    int length = (int) (in.remaining() * (double) encoder.averageBytesPerChar());
    ByteBuffer out = ByteBuffer.allocate(length);

    encoder.reset();
    CoderResult flushResult = null;

    while (flushResult != CoderResult.UNDERFLOW) {
        CoderResult encodeResult = encoder.encode(in, out, true);
        if (encodeResult == CoderResult.OVERFLOW) {
            out = allocateMore(out);
            continue; // No point trying to flush to an already-full buffer.
        }
        // getBytes()から呼び出されるときは、CodingErrorActionは
        // REPLACEなので、ここでエラーチェックする必要はない。

        flushResult = encoder.flush(out);
        if (flushResult == CoderResult.OVERFLOW) {
            out = allocateMore(out);
        }
        // getBytes()から呼び出されるときは、CodingErrorActionは
        // REPLACEなので、ここでエラーチェックする必要はない。
    }

    out.flip();
    return out;
}

private static ByteBuffer allocateMore(ByteBuffer output) {
    System.out.println("allocateMore()");
    if (output.capacity() == 0) {
        return ByteBuffer.allocate(1);
    }
    ByteBuffer result = ByteBuffer.allocate(output.capacity() * 2);
    output.flip();
    result.put(output);
    return result;
}

SJISへの変換がおかしくなる問題も、この方法で回避できましたので、お困りの方はお試しください。具体的には、上記コードをコピペした上で、以下のように使ってみてください。

// 「あ」をSJISに変換する。
byte[] sjisEncoded = getBytes("あ", Chrset.forName("Shift_JIS"));

github上のUUIHは近いうちに更新しようと思います。

バグ報告について

この事象について、AOSPにバグ報告しようと調べてみたところ、既に韓国語でバグ報告がなされていることがわかりました。

これによると、もうmasterブランチには修正版コミットされており、次回の4.4のアップデートでは配信される見込みのようです。もうしばらくの辛抱ですね…。

UiautomatorでASCII文字だけを確実に入力する

はじめに

前回の記事で、uiautomatorで日本語を入力できるようにするためにIMEを作った、という記事を書きました。

このIMEを入れれば、uiautomatorで日本語などの非ASCII文字を入力できるのはもちろん、英数字のようなASCII文字も確実に入力することができるのですが、中には「英数字だけ入力できればいいよ」という方もいるかも知れません。

実は、英数字(ASCII文字)だけ確実に入力できれば良いのであれば、Androidソースコードの中に、それを実現するためのIMEがこっそり同梱されています。
https://android.googlesource.com/platform/frameworks/testing/+/master/uiautomator/utils/
にある「DummyIME」というのがそれです。

このDummyIMEは、ソフトウェアキーボードを表示せず、ひたすらキー入力をEditTextにスルーするように実装されているので、UiObject.setText(String)で設定した通りの英数字を、確実に入力することができます。

この記事では、DummyIMEを使う方法について説明します。

DummyIMEのインストール

  • git cloneします。
git clone https://android.googlesource.com/platform/frameworks/testing
  • uiautomator/utils/DummyIMEへ移動します。
cd testing/uiautomator/utils/DummyIME
  • build.xmlを生成します。-t android-18の部分は、実行したい端末と同じOSバージョンのものにしておくと良いでしょう。
android update project -p . -t android-18
  • テスト対象の端末を接続し、antを使ってビルド・インストールします。
ant clean debug install

Uiautomatorの実行

さきほどの手順で、DummyIMEをインストールしておけば、uiautomatorコマンドの隠しオプションで、DummyIMEを有効化した上でテストを実行してくれます。
具体的には、下記のように-e disable_ime trueオプションを追加して実行してください。

adb shell uiautomator runtest <JARファイル名> -e disable_ime true -c <実行クラス名> 

このオプションはとても強力で、自動的にDummyIMEをデフォルトのIMEに設定してから、テストを実行してくれるのですが、テスト終了後に元のIMEに戻すことはしてくれません。
ソースコードを見ると、そもそも元のIMEに戻す手段が無いような雰囲気です。

まとめ

Androidソースコードにこっそり格納されているDummyIMEについて紹介しました。
英数字を入力したいだけであれば、DummyIMEを使うのもひとつの方法です。

ただ、前回の記事で紹介したUiautomator Unicode Input Helperを使えば、英数字に限らず、任意のUnicode文字を入力することができますので、是非お試しください。

Uiautomatorで日本語を入力する

はじめに

Android 4.1から使えるようになった、GUIの自動テストツールであるuiautomatorですが、以下のような特徴があり、とても便利です。

  • MonkeyRunnerのように、自分が書いたアプリ以外も操作することができる。
  • MonkeyRunnerと異なり、タップ位置を絶対座標で指定する必要がないため、画面サイズの異なる端末でも同一シナリオが適用できる。

ただ、EditTextへの文字入力に多少難があります。
Uiautomatorには、UiObject.setText(String)という、EditTextに文字列を設定するメソッドがあるのですが、ここに

UiObject editText = ...;
editText.setText("こんにちは");

のように書いても、何も入力されないのです。

このようになってしまう理由は、Uiautomatorが、ハードウェアキーボードのキーイベントを擬似的に発生させることで、文字入力を実現しているからのようです。
そのため、逆に、日本語IMEが有効な状態で

UiObject editText = ...;
editText.setText("aiueo");

とすると、ローマ字入力が有効になってしまい、「あいうえお」と入力されてしまいます…。

これを解決するために、ASCII図形文字だけで、任意のUnicode文字が入力できるようなIMEを実装してみました。これを使えば、EditTextへの文字入力は思いのままです!
GitHubに公開しましたので、テストのお供にご利用ください。

使い方

README.mdをご覧ください。

このIMEを有効にした状態で、以下のように書くと、「こんにちは!UiAutomatorで入力しています。」と入力することができます。

UiObject editText = ...;
// 「こんにちは!UiAutomatorで入力しています。」を修正UTF-7でエンコードしたもの
String e = "&MFMwkzBrMGEwb,8B-UiAutomator&MGdRZVKbMFcwZjBEMH4wWTAC-";
editText.setText(e);

もちろん、このままでは使いにくいので、以下のように書けるヘルパーライブラリもあわせて公開しています。

UiObject editText = ...;
editText.setText(Utf7ImeHelper.e("こんにちは!UiAutomatorで入力しています。"));

動作確認について

  • Nexus7 (2013) で動作確認しています。
  • Android4.2.2のエミュレータでも動作しますが、サンプルは動作しません。UiObjectの検索条件を書き換えれば動作します。

さいごに

Uiautomatorで日本語を入力するためのツールを作りました。

IMEとしては、ASCII文字の範囲で任意のUnicodeが表現できれば良いため、BASE64やQuoted-Printableなど、色々な変換方式が考えられたのですが、できるだけASCII文字はそのまま入力できるようにしたかったため、修正UTF-7 (RFC 3501)を採用しました。
修正UTF-7では、&以外のASCII文字であれば、ASCIIそのままの文字を使うことができます。

修正UTF-7をサポートするライブラリには、jutf7を使わせていただきました。

興味がありましたら使ってみてください。コメント歓迎です!