LoginSignup
5
10

More than 5 years have passed since last update.

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

Posted at

こんにちは。
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. しりきれ‐とんぼ【尻切れ蜻蛉】:物事が中途で切れて、完結しないことのたとえ。 

5
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
10