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引数
endOfInput
をtrue
にして呼び出す。そうでない場合には第3引数にはfalse
を指定する。 - 1回で全入力を渡せる場合は、いきなり第3引数に
true
を指定して呼び出せばよい。 - 入力が壊れていない場合、
encode(CharBuffer, ByteBuffer, boolean)
の結果は、CoderResult.UNDERFLOW
か、CoderResult.OVERFLOW
のいずれかになる。OVERFLOW
の場合は、出力バッファのサイズが足りていないので、出力バッファを拡張して、encode(CharBuffer, ByteBuffer, boolean)
を呼び直さなければならない。UNDERFLOW
は正常終了を表す。
- 一度に全入力を渡さず、何回かに分けて呼び出す場合には、一番最後の呼び出しのときだけ、第3引数
- 変換がおわったら
flush(ByteBuffer)
を呼ぶ
ところが、上記コードを見ると、encode(CharBuffer, ByteBuffer, boolean)
の結果がOVERFLOWとUNDERFLOWしか取らない場合では、(必ずbreak
かcontinue
に到達するので)絶対に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のアップデートでは配信される見込みのようです。もうしばらくの辛抱ですね…。