こんにちは。
Androidアプリから、Webサーバにあるファイルをダウンロードしようとしたけれども、そのWebサーバが突然停止してしまってダウンロードが頓挫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で察してください。
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カードにファイルを置けないようです。
面倒くさいですね。でも、Android 6.0未満であればそんな措置は不要(Android 6.0以上の端末でも、デフォルトでONになってくれる)なので、ここは楽をしてしまいたいと思います。
結論
アプリからダウンロードを始めたら、ダウンロードし切っちゃう前に、Webサーバを停止させちゃう(まぁ意地悪ね)。そうすると、
java.net.ProtocolExceptionが発生しました。
これが、結論です。
とはいえ、ここまで至るまでに、アレやコレやと、すったもんだがありました。それを以下に記します。
マニフェスト
<?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個だけの画面です。
<?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クラス
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型だからといって、油断してはいけません。
- 駄目
getApplicationContext()
- 駄目
getBaseContext()
- 良し
MainActivity.this
結局、Activityオブジェクトでなければならないです。
表示時に、外をクリックすると、消えちゃう
プログレスバー表示されている最中に、プログレスバーの外をクリックすると、プログレスバーが消えるというのがデフォルトの挙動です。かといって、それをしたらダウンロードのキャンセル(中断)かな?と勘違いさせられてしまうんですが、どっこい、ダウンロードの作業はし続けています。
setCanceledOnTouchOutside
メソッドの引数にfalseを渡せば、プログレスバーの外をクリックしても、プログレスバーは消えません。
AsyncTask
えてしてAsyncTaskのサブクラスは、Activityのネストクラスとして定義します。ステップ数が長くなるデメリットを孕んでいますが。
AsyncTaskのサブクラスの定義方法(Overrideするメソッドの定義も含めて)そのものの説明はここでは割愛いたします。それでは、肝心のdoInBackground
メソッドの中身の処理は以下のとおりです。(他のメソッドはプログレスバーのことばかり構っているにすぎませんので)。
ネット通信とファイルIOが同居しているので、例外がわかりづらい
ですが、各APIがスローする例外が多種多様…のようでいて、否、実は多種多様ではない、というのがややこしいです。
-
HttpURLConnection#setRequestMethod
は、java.net.ProtocolException
をスローしますが、この例外はIOException
のサブクラスです。 - 以下のメソッドは、意外や意外、例外をスローしません。
-
disconnect
は例外をスローしませんが、HttpURLConnection#connect
はIOException
をスローします。
URL
のコンストラクタはMalformedURLException
という例外をスローします。これもIOException
のサブクラスです。
次に、ファイルIOであるInputStream
とOutputStream
についてですが、これらはIOException
をスローします。
つまり、結局どの例外もIOException
のサブクラスなので、catch (IOException e) {}
で一網打尽3、十把一絡げ4にキャッチされるので、問題の切り分け・見分けが難しかったです。
もう一度、まとめとして結論
ダウンロード頓挫のときにスローされる例外はProtocolExceptionです。ただし当投稿ではjava.netパッケージのを使用していますので、OkHttpのようなライブラリではどうなるかわかりませんが。
追伸:頓挫してもなお
ダウンロードが中断しても、”途中までの分”はSDカードに保存されていました。本当は8枚の大きな画像ファイルが圧縮された11MBのZIPですが、5MBとか8MBとか中途半端なサイズで保存されます。で、そのZIPファイルを展開してみると、画像が3枚とか5枚とか出てきました。
ZIPのことを良く知っていない私にとっては不思議です。尻切れ蜻蛉5なファイルなので、いっそ”壊れたファイル”になるもんだと思っていたので、展開すらできないもんだと思っていました。
そして、再度ダウンロードのチャレンジ!は初めからのダウンロードになります。ファイルは上書き保存となります。
以上です。