blob: e748bbd2275c3af99c285b04d71a5e398a26991f [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.captiveportallogin;
import static java.lang.Math.min;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.drawable.Icon;
import android.icu.text.NumberFormat;
import android.net.Network;
import android.net.Uri;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.util.Log;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Foreground {@link Service} that can be used to download files from a specific {@link Network}.
*
* If the network is or becomes unusable, the download will fail: the service will not attempt
* downloading from other networks on the device.
*/
public class DownloadService extends Service {
private static final String TAG = DownloadService.class.getSimpleName();
@VisibleForTesting
static final String ARG_NETWORK = "network";
@VisibleForTesting
static final String ARG_USERAGENT = "useragent";
@VisibleForTesting
static final String ARG_URL = "url";
@VisibleForTesting
static final String ARG_DISPLAY_NAME = "displayname";
@VisibleForTesting
static final String ARG_OUTFILE = "outfile";
private static final String ARG_CANCEL = "cancel";
private static final String CHANNEL_DOWNLOADS = "downloads";
private static final String CHANNEL_DOWNLOAD_PROGRESS = "downloads_progress";
private static final int NOTE_DOWNLOAD_PROGRESS = 1;
private static final int NOTE_DOWNLOAD_DONE = 2;
private static final int CONNECTION_TIMEOUT_MS = 30_000;
// Update download progress up to twice/sec.
private static final long MAX_PROGRESS_UPDATE_RATE_MS = 500L;
private static final long CONTENT_LENGTH_UNKNOWN = -1L;
// All download job IDs <= this value should be cancelled
private volatile int mMaxCancelDownloadId;
@GuardedBy("mQueue")
private final Queue<DownloadTask> mQueue = new ArrayDeque<>(1);
@GuardedBy("mQueue")
private boolean mProcessing = false;
// Tracker for the ID to assign to the next download. The service startId is not used because it
// is not guaranteed to be monotonically increasing; increasing download IDs are convenient to
// allow cancelling current downloads when the user tapped the cancel button, but not subsequent
// download jobs.
private final AtomicInteger mNextDownloadId = new AtomicInteger(1);
private static class DownloadTask {
private final int mId;
private final Network mNetwork;
private final String mUserAgent;
private final String mUrl;
private final String mDisplayName;
private final Uri mOutFile;
private final Notification.Builder mCachedNotificationBuilder;
private DownloadTask(int id, Network network, String userAgent, String url,
String displayName, Uri outFile, Context context) {
this.mId = id;
this.mNetwork = network;
this.mUserAgent = userAgent;
this.mUrl = url;
this.mDisplayName = displayName;
this.mOutFile = outFile;
final Resources res = context.getResources();
final Intent cancelIntent = new Intent(context, DownloadService.class)
.putExtra(ARG_CANCEL, mId)
.setIdentifier(String.valueOf(mId));
final PendingIntent pendingIntent = PendingIntent.getService(context,
0 /* requestCode */, cancelIntent, 0 /* flags */);
final Notification.Action cancelAction = new Notification.Action.Builder(
Icon.createWithResource(context, R.drawable.ic_close),
res.getString(android.R.string.cancel),
pendingIntent).build();
this.mCachedNotificationBuilder = new Notification.Builder(
context, CHANNEL_DOWNLOAD_PROGRESS)
.setContentTitle(res.getString(R.string.downloading_paramfile, mDisplayName))
.setSmallIcon(R.drawable.ic_cloud_download)
.setOnlyAlertOnce(true)
.addAction(cancelAction);
}
}
/**
* Create an intent to be used to start the service.
*
* <p>The intent can then be used with {@link Context#startForegroundService(Intent)}.
* @param packageContext Context to use to resolve the {@link DownloadService}.
* @param network Network that the download should be done on. No other network will be
* considered for the download.
* @param userAgent UserAgent to use for the download request.
* @param url URL to download from.
* @param displayName Name of the downloaded file, to be used when displaying progress UI (does
* not affect the actual output file).
* @param outFile Output file of the download.
*/
public static Intent makeDownloadIntent(Context packageContext, Network network,
String userAgent, String url, String displayName, Uri outFile) {
final Intent intent = new Intent(packageContext, DownloadService.class);
intent.putExtra(ARG_NETWORK, network);
intent.putExtra(ARG_USERAGENT, userAgent);
intent.putExtra(ARG_URL, url);
intent.putExtra(ARG_DISPLAY_NAME, displayName);
intent.putExtra(ARG_OUTFILE, outFile);
return intent;
}
/**
* Create an intent to be used via {android.app.Activity#startActivityForResult} to create
* an output file that can be used to start a download.
*
* <p>This creates a {@link Intent#ACTION_CREATE_DOCUMENT} intent. Its result must be handled by
* the calling activity.
*/
public static Intent makeCreateFileIntent(String mimetype, String filename) {
final Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimetype);
intent.putExtra(Intent.EXTRA_TITLE, filename);
return intent;
}
@Override
public void onCreate() {
createNotificationChannels();
}
/**
* Called when the service needs to process a new command:
* - If the intent has ARG_CANCEL extra, all downloads with a download ID <= that argument
* should be cancelled.
* - Otherwise the intent indicates a new download (with network, useragent, url... args).
*
* This method may be called multiple times if the user selects multiple files to download.
* Files will be queued to be downloaded one by one; if the user cancels the current file, this
* will not affect the next files that are queued.
*/
@Override
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
if (intent == null) {
return START_NOT_STICKY;
}
final int cancelDownloadId = intent.getIntExtra(ARG_CANCEL, -1);
if (cancelDownloadId != -1) {
mMaxCancelDownloadId = cancelDownloadId;
return START_NOT_STICKY;
}
final Network network = intent.getParcelableExtra(ARG_NETWORK);
final String userAgent = intent.getStringExtra(ARG_USERAGENT);
final String url = intent.getStringExtra(ARG_URL);
final String filename = intent.getStringExtra(ARG_DISPLAY_NAME);
final Uri outFile = intent.getParcelableExtra(ARG_OUTFILE);
if (network == null || userAgent == null || url == null || filename == null
|| outFile == null) {
Log.e(TAG, String.format("Missing parameters; network: %s, userAgent: %s, url: %s, "
+ "filename: %s, outFile: %s", network, userAgent, url, filename, outFile));
return START_NOT_STICKY;
}
synchronized (mQueue) {
final DownloadTask task = new DownloadTask(mNextDownloadId.getAndIncrement(),
network.getPrivateDnsBypassingCopy(), userAgent, url, filename, outFile, this);
mQueue.add(task);
if (!mProcessing) {
startForeground(NOTE_DOWNLOAD_PROGRESS, makeProgressNotification(task,
null /* progress */));
new Thread(new ProcessingRunnable()).start();
}
mProcessing = true;
}
// If the service is killed the download is lost, which is fine because it is unlikely for a
// foreground service to be killed, and there is no easy way to know whether the download
// was really not yet completed if the service is restarted with e.g. START_REDELIVER_INTENT
return START_NOT_STICKY;
}
private void createNotificationChannels() {
final NotificationManager nm = getSystemService(NotificationManager.class);
final Resources res = getResources();
final NotificationChannel downloadChannel = new NotificationChannel(CHANNEL_DOWNLOADS,
res.getString(R.string.channel_name_downloads),
NotificationManager.IMPORTANCE_DEFAULT);
downloadChannel.setDescription(res.getString(R.string.channel_description_downloads));
nm.createNotificationChannel(downloadChannel);
final NotificationChannel progressChannel = new NotificationChannel(
CHANNEL_DOWNLOAD_PROGRESS,
res.getString(R.string.channel_name_download_progress),
NotificationManager.IMPORTANCE_LOW);
progressChannel.setDescription(
res.getString(R.string.channel_description_download_progress));
nm.createNotificationChannel(progressChannel);
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private class ProcessingRunnable implements Runnable {
@Override
public void run() {
while (true) {
final DownloadTask task;
synchronized (mQueue) {
task = mQueue.poll();
if (task == null) {
mProcessing = false;
stopForeground(true /* removeNotification */);
return;
}
}
processDownload(task);
}
}
private void processDownload(@NonNull final DownloadTask task) {
final NotificationManager nm = getSystemService(NotificationManager.class);
// Start by showing an indeterminate progress notification
nm.notify(NOTE_DOWNLOAD_PROGRESS, makeProgressNotification(task, null /* progress */));
URLConnection connection = null;
try {
final URL url = new URL(task.mUrl);
// This may fail if the network is not usable anymore, which is the expected
// behavior: the download should fail if it cannot be completed on the assigned
// network.
connection = task.mNetwork.openConnection(url);
connection.setConnectTimeout(CONNECTION_TIMEOUT_MS);
connection.setReadTimeout(CONNECTION_TIMEOUT_MS);
connection.setRequestProperty("User-Agent", task.mUserAgent);
long contentLength = CONTENT_LENGTH_UNKNOWN;
if (connection instanceof HttpURLConnection) {
final HttpURLConnection httpConn = (HttpURLConnection) connection;
final int responseCode = httpConn.getResponseCode();
if (responseCode < 200 || responseCode > 299) {
throw new IOException("Download error: response code " + responseCode);
}
contentLength = httpConn.getContentLengthLong();
}
try (ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(
task.mOutFile, "rwt");
FileOutputStream fop = new FileOutputStream(pfd.getFileDescriptor())) {
final InputStream is = connection.getInputStream();
if (!downloadToFile(is, fop, contentLength, task, nm)) {
// Download cancelled
tryDeleteFile(task.mOutFile);
// Don't clear the notification: this will be done when the service stops
// (foreground service notifications cannot be cleared).
return;
}
}
nm.notify(NOTE_DOWNLOAD_DONE,
makeDoneNotification(task.mId, task.mDisplayName, task.mOutFile));
} catch (IOException e) {
Log.e(DownloadService.class.getSimpleName(), "Download error", e);
nm.notify(NOTE_DOWNLOAD_DONE, makeErrorNotification(task.mDisplayName));
tryDeleteFile(task.mOutFile);
} finally {
if (connection instanceof HttpURLConnection) {
((HttpURLConnection) connection).disconnect();
}
}
}
/**
* Download the contents of an {@link InputStream} to a {@link FileOutputStream}, and
* updates the progress notification.
* @return True if download is completed, false if cancelled
*/
private boolean downloadToFile(@NonNull InputStream is, @NonNull FileOutputStream fop,
long contentLength, @NonNull DownloadTask task,
@NonNull NotificationManager nm) throws IOException {
final byte[] buffer = new byte[1500];
int allRead = 0;
final long maxRead = contentLength == CONTENT_LENGTH_UNKNOWN
? Long.MAX_VALUE : contentLength;
int lastProgress = -1;
long lastUpdateTime = -1L;
while (allRead < maxRead) {
if (task.mId <= mMaxCancelDownloadId) {
return false;
}
final int read = is.read(buffer, 0, (int) min(buffer.length, maxRead - allRead));
if (read < 0) {
// End of stream
break;
}
allRead += read;
fop.write(buffer, 0, read);
final Integer progress = getProgress(contentLength, allRead);
if (progress == null || progress.equals(lastProgress)) continue;
final long now = System.currentTimeMillis();
if (maybeNotifyProgress(progress, lastProgress, now, lastUpdateTime, task, nm)) {
lastUpdateTime = now;
}
lastProgress = progress;
}
return true;
}
private void tryDeleteFile(@NonNull Uri file) {
try {
// The file was not created by the DownloadService, however because the service
// is only usable from this application, and the file should be created from this
// same application, the content resolver should be the same.
DocumentsContract.deleteDocument(getContentResolver(), file);
} catch (FileNotFoundException e) {
// Nothing to delete
}
}
private Integer getProgress(long contentLength, long totalRead) {
if (contentLength == CONTENT_LENGTH_UNKNOWN || contentLength == 0) return null;
return (int) (totalRead * 100 / contentLength);
}
/**
* Update the progress notification, if it was not updated recently.
* @return True if progress was updated.
*/
private boolean maybeNotifyProgress(int progress, int lastProgress, long now,
long lastProgressUpdateTimeMs, @NonNull DownloadTask task,
@NonNull NotificationManager nm) {
if (lastProgress > 0 && progress < 100
&& lastProgressUpdateTimeMs > 0
&& now - lastProgressUpdateTimeMs < MAX_PROGRESS_UPDATE_RATE_MS) {
// Rate-limit intermediate progress updates: NotificationManager will start ignoring
// notifications from the current process if too many updates are posted too fast.
// The shown progress will not "lag behind" much in most cases. An alternative
// would be to delay the progress update to rate-limit, but this would bring
// synchronization problems.
return false;
}
final Notification note = makeProgressNotification(task, progress);
nm.notify(NOTE_DOWNLOAD_PROGRESS, note);
return true;
}
}
@NonNull
private Notification makeProgressNotification(@NonNull DownloadTask task,
@Nullable Integer progress) {
return task.mCachedNotificationBuilder
.setContentText(progress == null
? null
: NumberFormat.getPercentInstance().format(progress.floatValue() / 100))
.setProgress(100,
progress == null ? 0 : progress,
progress == null /* indeterminate */)
.build();
}
@NonNull
private Notification makeDoneNotification(int taskId, @NonNull String displayName,
@NonNull Uri outFile) {
final Intent intent = new Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setData(outFile)
.setIdentifier(String.valueOf(taskId));
final PendingIntent pendingIntent = PendingIntent.getActivity(
this, 0 /* requestCode */, intent, 0 /* flags */);
return new Notification.Builder(this, CHANNEL_DOWNLOADS)
.setContentTitle(getResources().getString(R.string.download_completed))
.setContentText(displayName)
.setSmallIcon(R.drawable.ic_cloud_download)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build();
}
@NonNull
private Notification makeErrorNotification(@NonNull String filename) {
final Resources res = getResources();
return new Notification.Builder(this, CHANNEL_DOWNLOADS)
.setContentTitle(res.getString(R.string.error_downloading_paramfile, filename))
.setSmallIcon(R.drawable.ic_cloud_download)
.build();
}
}