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

More than 1 year has passed since last update.

こんにちは。

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