Android 4.1.2 WebView クラッシュ問題

##ことのあらまし

Android開発者の手元に日々届くクラッシュレポート、しかしここのところずっとクラッシュ原因のトップを占めていたのはまったく身に覚えのないものでした。

```
java.lang.NullPointerException
       at android.webkit.HTML5VideoView.release(HTML5VideoView.java:255)
       at android.webkit.HTML5VideoViewProxy$VideoPlayer.suspend(HTML5VideoViewProxy.java:165)
       at android.webkit.HTML5VideoViewProxy.suspend(HTML5VideoViewProxy.java:874)
       at android.webkit.HTML5VideoViewManager.suspend(HTML5VideoViewManager.java:75)
       at android.webkit.WebViewClassic.loadUrlImpl(WebViewClassic.java:2764)
       at android.webkit.WebViewClassic.loadUrlImpl(WebViewClassic.java:2786)
       at android.webkit.WebViewClassic.loadUrl(WebViewClassic.java:2779)
       at android.webkit.WebView.loadUrl(WebView.java:807)
```

```
java.lang.NullPointerException
       at android.webkit.HTML5VideoView.release(HTML5VideoView.java:256)
       at android.webkit.HTML5VideoViewProxy$VideoPlayer.suspend(HTML5VideoViewProxy.java:173)
       at android.webkit.HTML5VideoViewProxy.handleMessage(HTML5VideoViewProxy.java:467)
       at android.os.Handler.dispatchMessage(Handler.java:99)
       at android.os.Looper.loop(Looper.java:213)
       at android.app.ActivityThread.main(ActivityThread.java:4786)
       at java.lang.reflect.Method.invokeNative(Method.java)
       at java.lang.reflect.Method.invoke(Method.java:511)
       at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:789)
```

なんだこれ `android.webkit.HTML5VideoView.release`


## 調査
このクラッシュはAndroidのOSバージョン4.1.2でしか発生していない。
クラスとメソッド名を見る限り、動画が含まれるWebページを見た後に、それを消すタイミングでNPEが出ているもよう。
だけどもいくら該当端末をいじっても再現が出来ない……

AndroidのJavaライブラリは公式のgitリポジトリで公開されている。
https://github.com/android/platform_frameworks_base

なので該当するソースコードを直接見てしまえば原因がわかる！はずなのだが……`release`なんてメソッドがどこにも見当たらない……

ところがスタックトレースの中にある`HTML5VideoViewManager`でググった結果、謎のgitリポジトリが見つかった。

https://github.com/xdtianyu/android_04.01.01_msm7627a

如何にも怪しい……が、確かに`HTML5VideoView.release`というメソッドがある！
https://github.com/xdtianyu/android_04.01.01_msm7627a/blob/master/frameworks/base/core/java/android/webkit/HTML5VideoView.java#L253

これさえあればこのファイルが公式gitリポジトリの中でどのコミットに含まれているか調べられるぞ。

```
$ cd platform-frameworks-base
$ curl -O https://raw.githubusercontent.com/xdtianyu/android_04.01.01_msm7627a/master/frameworks/base/core/java/android/webkit/HTML5VideoView.java
$ git hash-object HTML5VideoView.java
cbda6c6052009ae424fc59118ed8d78eb2b74caa
$ git cat-file -p cbda6c6052009ae424fc59118ed8d78eb2b74caa
fatal: Not a valid object name cbda6c6052009ae424fc59118ed8d78eb2b74caa
```

なんだよ……何なんだよお前……

## 解析
おそらくファイルが見つからない原因は、このファイルを保持していた開発ブランチが削除され、`git gc`でコミットごと消されてしまったためでしょう。何でそんなのが市場に出回っているのかはともかく……

こいつ誰だよといった疑問はさておき、ソースが存在しようがしまいが、クラッシュは存在しているのでとにかく調べる。

`Handler`から非同期的に呼ばれている方はちょっと手を出すのが難しそうなので、明示的にこちらが`WebView.loadUrl`を読んでいるところを追っていきましょう。

```WebView.java
    public void loadUrl(String url) {
        checkThread();
        mProvider.loadUrl(url);
    }
```

```WebViewClassic.java
    public void loadUrl(String url) {
        loadUrlImpl(url);
    }

    private void loadUrlImpl(String url) {
        if (url == null) {
            return;
        }
        loadUrlImpl(url, null);
    }

    private void loadUrlImpl(String url, Map<String, String> extraHeaders) {
        switchOutDrawHistory();
        if (mHTML5VideoViewManager != null)
            mHTML5VideoViewManager.suspend();
        WebViewCore.GetUrlData arg = new WebViewCore.GetUrlData();
        arg.mUrl = url;
        arg.mExtraHeaders = extraHeaders;
        mWebViewCore.sendMessage(EventHub.LOAD_URL, arg);
        clearHelpers();
    }
```

```Html5VideoViewManager.java
    public void suspend() {
        assert (mUiThread == Thread.currentThread());
        Iterator<HTML5VideoViewProxy> iter = mProxyList.iterator();
        while (iter.hasNext()) {
            HTML5VideoViewProxy proxy = iter.next();
            proxy.suspend();
        }
    }
```

```HTML5VideoViewProxy.java
    public void suspend() {
        mVideoPlayer.suspend();
    }
    
    private class VideoPlayer {
        public void suspend() {
            if (mHTML5VideoView != null) {
                mHTML5VideoView.pause();
                mCachedPosition = getCurrentPosition();
                mHTML5VideoView.release();
                setBaseLayer(0);
                mHTML5VideoView = null;
                isVideoSelfEnded = false;
                end();
            }
        }
    }
```

```HTML5VideoView.java
    public void release() {
        if (mCurrentState != STATE_RELEASED) {
            stopPlayback();
            mPlayer.release();
            mSurfaceTexture.release();
        }
        mCurrentState = STATE_RELEASED;
    }
```

`mSurfaceTexture`が`null`の場合に、ここで`NullPointerException`が発生している！

でなんでこいつが`null`なのか。原因は後述しますが、直接の理由は以下の通り。

```HTML5VideoView.java
    // SurfaceTexture will be created lazily here
    public SurfaceTexture getSurfaceTexture() {
        // Create the surface texture.
        if (mSurfaceTexture == null || mTextureNames == null) {
            mTextureNames = new int[1];
            GLES20.glGenTextures(1, mTextureNames, 0);
            mSurfaceTexture = new SurfaceTexture(mTextureNames[0]);
        }
        return mSurfaceTexture;
    }
```

`mSurfaceTexture`はコンストラクタで初期化されずに、必要になった際にlazy initializationされているよう。あの、`release`でも必要みたいなんですが……

## 原因

さて、なぜ`mSurfaceTexture`が`null`になるのか。まず`HTML5VideoView`を作っている部分は`HTML5VideoViewProxy.VideoPlayer`のこのメソッド。

```HTML5VideoViewProxy.java
        private boolean ensureHTML5VideoView(String url, int time, boolean willPlay) {
            if (mHTML5VideoView == null) {
                mHTML5VideoView = new HTML5VideoView(mProxy, time);
                mHTML5VideoView.setStartWhenPrepared(willPlay);
                mHTML5VideoView.setVideoURI(url);
                return true;
            }
            return false;
        }
```

そして`ensureHTML5VideoView`を呼んでいるのはこやつら。

```HTML5VideoViewProxy.java
        public void enterFullscreenVideo(String url, float x, float y, float w, float h) {
            if (ensureHTML5VideoView(url, mCachedPosition, false)) {
                mHTML5VideoView.prepareDataAndDisplayMode();
            }
            mHTML5VideoView.enterFullscreenVideoState(mWebView, x, y, w, h);
        }

        public void loadMetadata(String url) {
            if (ensureHTML5VideoView(url, 0, false)) {
                mHTML5VideoView.retrieveMetadata(mProxy);
            }
        }

        public void load(String url) {
            if (ensureHTML5VideoView(url, 0, false)) {
                mHTML5VideoView.prepareDataAndDisplayMode();
            }
        }

        public void play(String url, int time) {
            if (ensureHTML5VideoView(url, time, true)) {
                mHTML5VideoView.prepareDataAndDisplayMode();
                mHTML5VideoView.seekTo(time);
            } else {
                // Here, we handle the case when we keep playing with one video
                if (!mHTML5VideoView.isPlaying()) {
                    mHTML5VideoView.start();
                    setBaseLayer(0);
                }
            }
        }
```

`HTML5VideoView#prepareDataAndDisplayMode`の中身はこうなっている。

```HTML5VideoView.java
    public void prepareDataAndDisplayMode() {
        decideDisplayMode();
        ...
    }

    public void decideDisplayMode() {
        SurfaceTexture surfaceTexture = getSurfaceTexture();
        Surface surface = new Surface(surfaceTexture);
        mPlayer.setSurface(surface);
        surface.release();
    }
```

この中ではちゃんと`mSurfaceTexture`が初期化されている……つまり唯一これを呼んでいない`loadMetaData`！　おまえか！　おまえか、このやろう！

でこの`loadMetaData`がどういうときに呼び出されているかというと、どうやらHTMLで

```html
<video src="movie.mp4" preload="metadata"></video>
```

として動画のサイズや長さなどの情報をプリロードしているときのよう。しかしこのようなHTMLを用意しても結局クラッシュは再現できなかった。タスケテ……タスケテ……

## 対策
以上の処理を見る限り、このNPEをナントカ倒してしまえば他の部分へ影響が出ることは無いだろう。オーバーライドかなにかで`HTML5VideoView.release`処理を差し替えることができればよかったのだが、どうにも難しそう……なので、次のような対策を考えた。

* `release`処理で`mSurfaceTexture`が`null`になっているのが落ちる原因である。
* こいつは`getSurfaceTexture`で遅延初期化されるフィールド。
* 従って`release`直前に`getSurfaceTexture`を呼んでしまえば、無駄にSurfaceTextureを生成/破棄してしまうがともかくクラッシュは解消される。
* 正常な処理では`mSurfaceTexture != null`のはずだから、余分に`getSurfaceTexture`を呼んでも何も問題ない。

つまり、`WebView.loadUrl`を呼ぶ手前で次のような処理をあらかじめ行えばよい！

```java
HTML5VideoViewManager manager = webView.mProvider.mHTML5VideoViewManager;
if (manager != null) {
    for (HTML5VideoViewProxy proxy : manager.mProxyList) {
        HTML5VideoView videoView = proxy.mVideoPlayer.mHTML5VideoView;
        if (videoView != null) {
            videoView.getSurfaceTexture();
        }
    }
}
```

ただ問題なのは、このコードに出てくる変数やらクラスやらは当然privateなのでまともにアクセスすることができません。どうする？　そうだね、リフレクションだね。

## できあがったものがこちらです
```MyWebView.java
public class MyWebView extends WebView {
    @Override
    public void loadUrl(String url) {
        fixHTML5VideoViewCrash();
        super.loadUrl(url);
    }

    private void fixHTML5VideoViewCrash() {
        if (!Build.VERSION.RELEASE.equals("4.1.2")) {
            return;
        }

        HTML5VideoViewReflectionObjects r = HTML5VideoViewReflectionObjects.INSTANCE;
        if (r == null) {
            return;
        }

        Object provider = r.getWebViewProviderMethod.invoke(this);
        Object manager = r.mHTML5VideoViewManagerField.get(provider);
        if (manager != null) {
            List<?> proxyList = (List<?>) r.mProxyListField.get(manager);
            for (Object proxy : proxyList) {
                Object videoPlayer = r.mVideoPlayerField.get(proxy);
                Object videoView = r.mHTML5VideoViewField.get(videoPlayer);
                if (videoView != null) {
                    r.getSurfaceTextureMethod.invoke(videoView);
                }
            }
        }
    }

    private static class HTML5VideoViewReflectionObjects {
        public static final HTML5VideoViewReflectionObjects INSTANCE = create();

        public final Class<?> WebViewClassicClass;
        public final Class<?> HTML5VideoViewManagerClass;
        public final Class<?> HTML5VideoViewProxyClass;
        public final Class<?> HTML5VideoViewProxy$VideoPlayerClass;
        public final Class<?> HTML5VideoViewClass;

        public final Method getWebViewProviderMethod;
        public final Field mHTML5VideoViewManagerField;
        public final Field mProxyListField;
        public final Field mVideoPlayerField;
        public final Field mHTML5VideoViewField;
        public final Method getSurfaceTextureMethod;

        public HTML5VideoViewReflectionObjects() throws ClassNotFoundException, NoSuchFieldException, NoSuchMethodException {
            WebViewClassicClass = Class.forName("android.webkit.WebViewClassic");
            HTML5VideoViewManagerClass = Class.forName("android.webkit.HTML5VideoViewManager");
            HTML5VideoViewProxyClass = Class.forName("android.webkit.HTML5VideoViewProxy");
            HTML5VideoViewProxy$VideoPlayerClass = Class.forName("android.webkit.HTML5VideoViewProxy$VideoPlayer");
            HTML5VideoViewClass = Class.forName("android.webkit.HTML5VideoView");

            getWebViewProviderMethod = WebView.class.getDeclaredMethod("getWebViewProvider");
            mHTML5VideoViewManagerField = WebViewClassicClass.getDeclaredField("mHTML5VideoViewManager");
            mProxyListField = HTML5VideoViewManagerClass.getDeclaredField("mProxyList");
            mVideoPlayerField = HTML5VideoViewProxyClass.getDeclaredField("mVideoPlayer");
            mHTML5VideoViewField = HTML5VideoViewProxy$VideoPlayerClass.getDeclaredField("mHTML5VideoView");
            getSurfaceTextureMethod = HTML5VideoViewClass.getDeclaredMethod("getSurfaceTexture");

            AccessibleObject.setAccessible(new AccessibleObject[]{
                    mHTML5VideoViewManagerField, mProxyListField, mVideoPlayerField, mHTML5VideoViewField
            }, true);
        }

        public static HTML5VideoViewReflectionObjects create() {
            try {
                return new HTML5VideoViewReflectionObjects();
            } catch (Exception e) {
                return null;
            }
        }
    }
}
```

## 結果
これを組み込んだ結果、`loadUrl`からのクラッシュはとりあえず発生しなくなったはず。しかしもう一方の`Handler`から呼ばれている方は未だに解決できていない……。この手記を見つけた誰かが屍を乗り越えて跡を継いでくれると信じ、ここに筆を置きます。