Edited at

野良apkにアップデート機能をつけた話

More than 1 year has passed since last update.

野良配布しているアプリがありまして、それをアップデートする際に、従来は、ダウンロード・ページのURLをインテントで呼び出して、標準のブラウザなりchromeなりでダウンロードしてもらって、あとはパッケージ・インストーラがよしなにしてくれていたのですが、最近、ダウンロード・フォルダからだとインストーラが起動してくれなくなりました。セキュリティ的に危ないですもんね。

とはいえ、アプリの配布手順が複雑になってしまっていて困ったので、自前でダウンロードして、パッケージ・インストーラの起動までシームレスに実行するようにしました。かなりニッチなニーズですが、参考までに紹介しようと思います。


まずはダウンロード(WebView経由)

前述のように従来は、ダウンロード・ページからダウンロードするようになっていたのと、セッションをCookieで管理していた都合上、いったんWebViewで開く必要がありました。

    


webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView webView, String url) {
if (url.startsWith("http:") || url.startsWith("https:")) {
return false;
} else {
return true;
}
}
@Override
public void onPageFinished(WebView view, String url){
// Cookeiは後で使うので、インスタンス変数に保持
cookie = CookieManager.getInstance().getCookie(url);
}
});
webView.loadUrl(ダウンロードページのURL);


ま、普通のWebViewの使い方ですね。Cookieを取得したいだけです。

あとは、ファイルのダウンロードですが、これにはDownloadManagerを使います。

    


webView.setDownloadListener(new DownloadListener() {
@Override
public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength) {
DownloadManager manager = (DownloadManager)getSystemService(Activity.DOWNLOAD_SERVICE);
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url));
// ここでCookieを渡す
request.addRequestHeader("Cookie", cookie);
// いわゆるダウンロード・フォルダ
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
// ファイル名は適当なものを使う
request.setDestinationUri(Uri.fromFile(new File(dir, FILE_NAME + APK)));
request.setDescription(contentDisposition);
// 履歴は残さなくていいや
request.setVisibleInDownloadsUi(false);
// このアプリのユーザはwi-fi?なにそれ?なので、電話回線でもダウンロードする
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_MOBILE | DownloadManager.Request.NETWORK_WIFI);
request.setMimeType(mimetype);
// fileIdは、後で使うので、インスタンス変数で保持
fileId = manager.enqueue(request);
}
});


これで指定したフォルダに指定したファイル名(既にある場合は、連番が付いたりする)でダウンロードされます。

ここでは、もともとの配布ページがインストール用のパッケージしかダウンロードしないようになっているので省いていますが、urlをみて目的のファイルかどうかを判別した方がよいでしょう。


終了を検知

いうまでもなく、DownloadManagerの動作は非同期に行われます。

なので、終了を検知しないとその後のインストーラの起動ができません。

それについては、DownloadManagerがインテントをブロードキャストしてくれるので、インテント・フィルタにDownloadManager.ACTION_DOWNLOAD_COMPLETEを指定して、BroadcastReceiverで受け取ります。

private class CompleteReceiver extends BroadcastReceiver {

@Override
public void onReceive(@NotNull Context context, @NotNull Intent intent) {
String action = intent.getAction();

if (action.equals(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) {
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
if (id == fileId) {
DownloadManager.Query query = new DownloadManager.Query();
query.setFilterByStatus(DownloadManager.STATUS_FAILED);
query.setFilterByStatus(DownloadManager.STATUS_SUCCESSFUL);
query.setFilterById(id);
DownloadManager manager = (DownloadManager)context.getSystemService(Activity.DOWNLOAD_SERVICE);
Cursor cursor = manager.query(query);
if (cursor.moveToFirst()) {
int status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
String url = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI));
if (status == DownloadManager.STATUS_SUCCESSFUL) {
// ここでインストーラ呼び出し
install(context, url);
}
// エラーのときはよしなに
}
cursor.close();
// WebViewの片付けが必要ならここのタイミング
}
}
}
}

DownloadManagerはダウンロードの終了をブロードキャストするので、自分が落とそうとしているファイル以外でも、ダウンロードが終わればインテントを投げてきます。

そのため、依頼したときのidと比較して、一致したときだけその後の処理をするようにしましょう。

あと、さきに少しふれましたが、既に落とそうとしているファイル名と同名のファイルが存在する場合は、「-1」「-2」のような接尾辞を付加してきます。そこで、DownloadManager.COLUMN_LOCAL_URIで、実際に落としたファイルのURIを取り出します。


インストーラの起動

インストーラの呼び出しは、インテントを投げるだけです。

private void install(Context context, String url) {

Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.parse(url), "application/vnd.android.package-archive");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}

肝は、intent.setDataAndType()で渡しているタイプです。

あとは、インテント・フィルタでパッケージをインストールできるアプリが選択され、起動します。

注意するところとしては、インテント・フラグに、FLAG_ACTIVITY_NEW_TASKを設定して、別タスクで起動しないと、パッケージのインストールと同時に、元のアプリがインストーラを巻き込んで終了してしまいます。

ちゃんと、インストールはされているのですが、インストーラがクラッシュしたかと思うような落ち方をするので、精神衛生上、よろしくありません。


まとめ

DownloadManagerとそれが投げるインテントを受け取るBroadcastReceiver、あとは、インストーラを起動するためのインテントの使い方がわかれば、apkをダウンロードしてインストールすることができます。

ただ、これはアップデートのときは使えますが、最初の配布についての手間は解決してくれません。orz


MarshmelloやNougatでややこしいことになったので追記


Marshmello

Marshmelloで、パーミッションの確認を実行時に行うようになったので、上記のコードだけだと、SecurityExceptionで落ちてしまいます。

とはいっても、DownloadListener#onDownloadStart()で、確認処理を挟むのは厳しいし、この機能はファイルをダウンロードするのが前提なので、最初に許可を取ってからWebView#loadUrlを呼ぶようにしました。ちょっと負けた感がありますが、よしとします。


Nougat

Nougatでは、さらに、「file:〜」なURIをインテントで直接渡せなくなりました。

FileProviderを使って渡す必要があります。

FileProviderでぐぐれば、使い方は見つけられると思いますが、Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)を共有パスにする方法がわからなかったので、Environment.getExternalStorageDirectory()以下にダウンロードするように変更しました(ここでもちょっと負けた感)。

後は、FileProvider.getUriForFile(context, "マニフェストで指定したAuthority", new File(Uri.parse(url).getPath()))で得られるUriを使って、インストーラにIntentを投げます。

// Uriを受け取るように変更

private void install(Context context, Uri uri) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(uri, "application/vnd.android.package-archive");
// 呼び出し先で読めるようにフラグを追加
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION);
context.startActivity(intent);
}