Androidでファイルのダウンロードが頓挫したら

こんにちは。
Androidアプリから、Webサーバにあるファイルをダウンロードしようとしたけれども、そのWebサーバが突然停止してしまって:scream:ダウンロードが頓挫[^1]した場合、どのような挙動になるかを調べてみました。

[^1]: とん‐ざ【頓挫】：計画や事業などが途中で遂行できなくなること。

# 前提

やりたいことは以下の通りです。

- Androidネイティブアプリである。
- Webサーバからダウンロードするファイルのサイズは、そこそこ大きい。
- ダウンロードしたファイルは、SDカードに保存する。
- HTTP通信は、java.netパッケージを使用します。[OkHttp](http://square.github.io/okhttp/)などのライブラリは使用しません。
- HTTP通信をしますし、ダウンロードに時間がかかりそうなのでAsyncTaskを利用します。

Webサーバは何でもいいと思うのですが、手元に[Apache Tomcat](http://tomcat.apache.org/)があったので、それを使いました。Tomcat8のbinフォルダ内にあるstartup.batとshutdown.batを手動で起動して”突然停止した”を自作自演してみます。

ダウンロードしたいファイルは、サイズが3000×2000ほどのJPG画像が8枚ほど入ったZIPファイルです。11MBぐらいです。あまり軽量なファイルだと、ぼやぼやしているあいまに、ダウンロードが完了しちゃいます。やりたいことは、**ダウンロードの頓挫**ですので。

開発環境は、Windows7(64bit)＋Android Studio 2.3.3.0です。エミュレータはAPI 23（Marshmallow）のを使いました。その他の設定は以下のbuild.gradleで察してください。

``` groovy:build.gradle
apply plugin: 'com.android.application'

android {
    compileSdkVersion 25
    buildToolsVersion "26.0.0"
    defaultConfig {
        applicationId "jp.co.casareal.asynctaskdownloader"
        minSdkVersion 19
        // Marshmallow(23)からアプリ個別に外部ストレージ権限許可が必要になった。22であれば不要なので楽できる。
        targetSdkVersion 22
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:25.3.1'
}
```

targetSdkVersionに附したコメントについてですが、Android 6.0以上の端末の［設定］→［アプリ］から当アプリの→［許可］にて、→［ストレージ］がデフォルトでOFFになっているので、ONにしなければSDカードにファイルを置けないようです。
![29c66d80-fdd8-a1fb-bdea-7f4486a454a0.png](https://qiita-image-store.s3.amazonaws.com/0/100612/c8d9026d-e4b1-9ea0-9ae2-94cc8730f9ff.png)


面倒くさいですね。でも、Android 6.0未満であればそんな措置は不要（Android 6.0以上の端末でも、デフォルトでONになってくれる）なので、ここは楽をしてしまいたいと思います。

# 結論

アプリからダウンロードを始めたら、ダウンロードし切っちゃう前に、Webサーバを停止させちゃう（まぁ意地悪ね）。そうすると、
**java.net.ProtocolExceptionが発生しました。**
これが、結論です。
とはいえ、ここまで至るまでに、アレやコレやと、すったもんだがありました。それを以下に記します。

# マニフェスト

```xml:AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="jp.co.casareal.asynctaskdownloader">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
```
インターネットと外部ストレージの許可がポイントですね。

# 画面レイアウト

ボタン1個だけの画面です。

![cad11504-071e-043a-5b0d-73cfa71cb3c4.png](https://qiita-image-store.s3.amazonaws.com/0/100612/7c4204bc-86fc-2875-551e-7401588154f3.png)

```xml:activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:onClick="onClickDownloadButton"
        android:text="start download" />

</LinearLayout>
```
このボタン押下時のために、onClickDownloadButtonメソッドをActivityに実装します。

# Activityクラス

```java:MainActivity.java
package jp.co.casareal.asynctaskdownloader;

import android.app.ProgressDialog;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.View;
import android.widget.Toast;

import java.io.BufferedInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;

public class MainActivity extends AppCompatActivity {

    static final String DOWNLOAD_FILE_NAME = "photos.zip";
    static final String DOWNLOAD_URL = "http://10.0.2.2:8080/";
    static final int TIMEOUT_SECOND = 60000;

    ProgressDialog progressDialog;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 駄目× getApplicationContext() ／ 駄目× getBaseContext() ／ 良し◎ MainActivity.this
        progressDialog = new ProgressDialog(MainActivity.this);
        progressDialog.setMessage("ダウンロード進捗");
        progressDialog.setIndeterminate(false);
        progressDialog.setMax(100);
        progressDialog.setCanceledOnTouchOutside(false);
        progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
    }

    public void onClickDownloadButton(View view) {
        DownLoadTask task = new DownLoadTask();
        task.execute(DOWNLOAD_URL + DOWNLOAD_FILE_NAME);
    }

    private class DownLoadTask extends AsyncTask<String, Integer, Boolean> {
        private final String TAG = DownLoadTask.class.getSimpleName();

        @Override
        protected void onPreExecute() {
            progressDialog.show();
        }

        @Override
        protected Boolean doInBackground(String... params) {
            URL url;
            try {
                url = new URL(params[0]);
            } catch (MalformedURLException e) {
                Log.e(getClass().getSimpleName(), "誤ったURL書式 : " + params[0], e);
                return null;
            }

            HttpURLConnection conn = null;

            long total = 0;

            try {
                conn = (HttpURLConnection) url.openConnection();
                conn.setRequestMethod("GET");
                // Keep-Aliveを無効にしないと、2回目の通信時にReadTimeOutを喰らう
                if ( Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB_MR2 &&
                        Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT ){
                    conn.setRequestProperty("Connection", "close");
                }
                conn.setReadTimeout(TIMEOUT_SECOND);
                conn.setConnectTimeout(TIMEOUT_SECOND);
                conn.connect();
                // ファイルサイズの取得（％表示するため）
                int fileLength = conn.getContentLength();
                // SDカードのパスは環境によって異なるので、動的に取得する
                String sdPath = Environment.getExternalStorageDirectory().getPath();
                String filePath = sdPath + "/" + DOWNLOAD_FILE_NAME;

                // ファイルダウンロード
                try (InputStream input = new BufferedInputStream(conn.getInputStream());
                     OutputStream output = new FileOutputStream(filePath)) {
                    byte data[] = new byte[1024];
                    int count;
                    while ((count = input.read(data)) != -1) {
                        total += count;
                        // ProgressBarに進捗を表示してもらう
                        publishProgress((int) (total * 100 / fileLength));
                        output.write(data, 0, count);
                    }
                    output.flush();
                }
            } catch (ProtocolException e) {
                // ここが「ダウンロードの頓挫」
                Log.e(TAG, "total received = " + total, e);
                return false;
            } catch (IOException e) {
                Log.e(TAG, e.getMessage(), e);
                return false;
            } finally {
                if (conn != null) {
                    try {
                        conn.disconnect();
                    } catch (Exception ignore) {
                        Log.e(TAG, ignore.getMessage(), ignore);
                    }
                }
            }
            return true;
        }

        @Override
        protected void onProgressUpdate(Integer... progress) {
            progressDialog.setProgress(progress[0]);
        }

        @Override
        protected void onPostExecute(Boolean success) {
            progressDialog.dismiss();
            String toastMessage = (success) ? "ダウンロード完了" : "ダウンロード失敗";
            Toast.makeText(getApplicationContext(), toastMessage, Toast.LENGTH_SHORT).show();
        }
    }
}
```
全コードを掲載しちゃいましたが、ポイントというか陥穽[^2]がいくつかあるので、ひとつひとつ説明していきます。

[^2]: かん-せい【陥穽】：おとしあな。わな。

## プログレスバー（ProgressDialog）

今回は、ファイルのダウンロード進捗度をパーセントでユーザに見せ付けたいので、プログレスバーを表示させます。クラスの名前は`ProgressDialog`なので、「プログレスダイアログ」と呼称したほうがいいのかもしれませんが。

### コンストラクタの引数のContext

Context型だからといって、油断してはいけません。

- 駄目:anger: `getApplicationContext()`
- 駄目:anger: `getBaseContext()`
- 良し:sparkling_heart: `MainActivity.this`

結局、Activityオブジェクトでなければならないです。

### 表示時に、外をクリックすると、消えちゃう

プログレスバー表示されている最中に、プログレスバーの外をクリックすると、プログレスバーが消えるというのがデフォルトの挙動です。かといって、それをしたらダウンロードのキャンセル（中断）かな？と勘違いさせられてしまうんですが、どっこい、ダウンロードの作業はし続けています。

`setCanceledOnTouchOutside`メソッドの引数にfalseを渡せば、プログレスバーの外をクリックしても、プログレスバーは消えません。

## AsyncTask

えてしてAsyncTaskのサブクラスは、Activityのネストクラスとして定義します。ステップ数が長くなるデメリットを孕んでいますが。

AsyncTaskのサブクラスの定義方法（Overrideするメソッドの定義も含めて）そのものの説明はここでは割愛いたします。それでは、肝心の`doInBackground`メソッドの中身の処理は以下のとおりです。（他のメソッドはプログレスバーのことばかり構っているにすぎませんので）。

### ネット通信とファイルIOが同居しているので、例外がわかりづらい:weary:

ですが、各APIがスローする例外が多種多様…のようでいて、否、実は多種多様ではない、というのがややこしいです。

- [`HttpURLConnection#setRequestMethod`](https://docs.oracle.com/javase/jp/8/docs/api/java/net/HttpURLConnection.html#setRequestMethod-java.lang.String-)は、`java.net.ProtocolException`をスローしますが、この例外は`IOException`のサブクラスです。
- 以下のメソッドは、意外や意外、例外をスローしません。 
    - [`HttpURLConnection#setReadTimeout`](https://docs.oracle.com/javase/jp/8/docs/api/java/net/URLConnection.html#setReadTimeout-int-)
    - [`HttpURLConnection#setConnectTimeout`](https://docs.oracle.com/javase/jp/8/docs/api/java/net/URLConnection.html#setConnectTimeout-int-)
    - [`HttpURLConnection#setRequestProperty`](https://docs.oracle.com/javase/jp/8/docs/api/java/net/URLConnection.html#setRequestProperty-java.lang.String-java.lang.String-)
    - [`HttpURLConnection#disconnect`](https://docs.oracle.com/javase/jp/8/docs/api/java/net/HttpURLConnection.html#disconnect--)
- `disconnect`は例外をスローしませんが、[`HttpURLConnection#connect`](https://docs.oracle.com/javase/jp/8/docs/api/java/net/URLConnection.html#connect--)は`IOException`をスローします。

`URL`のコンストラクタは`MalformedURLException`という例外をスローします。これも`IOException`のサブクラスです。

次に、ファイルIOである`InputStream`と`OutputStream`についてですが、これらは`IOException`をスローします。

つまり、**結局どの例外も`IOException`のサブクラス**なので、`catch (IOException e) {}`で一網打尽[^3]、十把一絡げ[^4]にキャッチされるので、問題の切り分け・見分けが難しかったです。:dizzy_face:

[^3]: いちもう‐だじん【一網打尽】：一度打った網でそこにいる魚を全部捕らえること。
[^4]: じっぱ‐ひとからげ【十把一絡げ】：いろいろな種類のものを、区別なしにひとまとめにして扱うこと。

# もう一度、まとめとして結論

**ダウンロード頓挫のときにスローされる例外はProtocolException**です。ただし当投稿ではjava.netパッケージのを使用していますので、OkHttpのようなライブラリではどうなるかわかりませんが。

# 追伸：頓挫してもなお

ダウンロードが中断しても、”途中までの分”はSDカードに保存されていました。本当は8枚の大きな画像ファイルが圧縮された11MBのZIPですが、5MBとか8MBとか中途半端なサイズで保存されます。で、そのZIPファイルを展開してみると、画像が3枚とか5枚とか出てきました。

![54d3bffa-3289-fb1e-0b4c-20901c43c041.png](https://qiita-image-store.s3.amazonaws.com/0/100612/8ad65dc1-e0b6-b1fc-9f24-ba93ff8b21ea.png)

ZIPのことを良く知っていない私にとっては不思議です。尻切れ蜻蛉[^5]なファイルなので、いっそ”壊れたファイル:ghost:”になるもんだと思っていたので、展開すらできないもんだと思っていました。

[^5]: しりきれ‐とんぼ【尻切れ蜻蛉】：物事が中途で切れて、完結しないことのたとえ。

そして、再度ダウンロードのチャレンジ！は初めからのダウンロードになります。ファイルは上書き保存となります。

以上です。