Android
ダウンロード
AsyncTask
SDカード
ProtocolException

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

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

前提

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

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

Webサーバは何でもいいと思うのですが、手元にApache Tomcatがあったので、それを使いました。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で察してください。

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

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

結論

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

マニフェスト

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

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クラス

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がいくつかあるので、ひとつひとつ説明していきます。

プログレスバー(ProgressDialog)

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

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

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

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

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

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

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

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

AsyncTask

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

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

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

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

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

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

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

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

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

追伸:頓挫してもなお

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

54d3bffa-3289-fb1e-0b4c-20901c43c041.png

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

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

以上です。


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

  2. かん-せい【陥穽】:おとしあな。わな。 

  3. いちもう‐だじん【一網打尽】:一度打った網でそこにいる魚を全部捕らえること。 

  4. じっぱ‐ひとからげ【十把一絡げ】:いろいろな種類のものを、区別なしにひとまとめにして扱うこと。 

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