sumioの技術メモ

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

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のアップデートでは配信される見込みのようです。もうしばらくの辛抱ですね…。