blob: cb4461e9e59cc2dbee0d666b5db9dc631bbabf5b [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.Binder;
import android.os.IBinder;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.util.Log;
import androidx.annotation.GuardedBy;
import androidx.annotation.IntDef;
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.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Objects;
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_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;
static final int DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE = 1;
@IntDef(value = { DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE })
@Retention(RetentionPolicy.SOURCE)
public @interface AbortedReason {}
// All download job IDs <= this value should be cancelled
private volatile int mMaxCancelDownloadId;
@GuardedBy("mQueue")
private final Queue<DownloadTask> mQueue = new LinkedList<>();
@GuardedBy("mQueue")
private boolean mProcessing = false;
@Nullable
@GuardedBy("mBinder")
private ProgressCallback mProgressCallback;
@NonNull
private final DownloadServiceBinder mBinder = new DownloadServiceBinder();
// 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);
// Key is the directly open MIME type with an int as it max length bytes. The value is an int is
// enough since it's no point if > 2**31.
private static final HashMap<String, Integer> sDirectlyOpenMimeType =
new HashMap<String, Integer>();
static {
sDirectlyOpenMimeType.put("application/x-wifi-config", 100_000);
}
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 String mMimeType;
private final Notification.Builder mCachedNotificationBuilder;
private DownloadTask(int id, Network network, String userAgent, String url,
String displayName, Uri outFile, Context context, String mimeType) {
this.mId = id;
this.mNetwork = network;
this.mUserAgent = userAgent;
this.mUrl = url;
this.mDisplayName = displayName;
this.mOutFile = outFile;
this.mMimeType = mimeType;
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, PendingIntent.FLAG_IMMUTABLE);
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 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;
}
// 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 int enqueueDownloadTask(Network network, String userAgent, String url, String filename,
Uri outFile, Context context, String mimeType) {
final DownloadTask task = new DownloadTask(mNextDownloadId.getAndIncrement(),
network.getPrivateDnsBypassingCopy(), userAgent, url, filename, outFile,
context, mimeType);
synchronized (mQueue) {
mQueue.add(task);
if (!mProcessing) {
startForeground(NOTE_DOWNLOAD_PROGRESS, makeProgressNotification(task,
null /* progress */));
new Thread(new ProcessingRunnable()).start();
}
mProcessing = true;
}
return task.mId;
}
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 mBinder;
}
class DownloadServiceBinder extends Binder {
public int requestDownload(Network network, String userAgent, String url, String filename,
Uri outFile, Context context, String mimeType) {
return enqueueDownloadTask(network, userAgent, url, filename, outFile, context,
mimeType);
}
public void cancelTask(int taskId) {
synchronized (mQueue) {
// If the task is no longer in the queue, it mean the download is in progress or
// already completed. Set the cancel id to this requested id.
if (!mQueue.removeIf(e -> e.mId == taskId)) {
mMaxCancelDownloadId = taskId;
}
}
}
public void setProgressCallback(ProgressCallback callback) {
synchronized (mBinder) {
mProgressCallback = callback;
}
}
}
/**
* Callback for notifying the download progress change.
*/
interface ProgressCallback {
/** Notify the requested download task is completed. */
void onDownloadComplete(@NonNull Uri inputFile, @NonNull String mimeType, int downloadId,
boolean success);
/** Notify the requested download task is aborted. */
void onDownloadAborted(int downloadId, @AbortedReason int reason);
}
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
updateNotification(nm, NOTE_DOWNLOAD_PROGRESS, task.mMimeType,
makeProgressNotification(task, null /* progress */));
URLConnection connection = null;
boolean downloadSuccess = false;
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;
}
}
downloadSuccess = true;
updateNotification(nm, NOTE_DOWNLOAD_DONE, task.mMimeType,
makeDoneNotification(task.mId, task.mDisplayName, task.mOutFile));
} catch (IOException e) {
Log.e(DownloadService.class.getSimpleName(), "Download error", e);
updateNotification(nm, NOTE_DOWNLOAD_DONE, task.mMimeType,
makeErrorNotification(task.mDisplayName));
tryDeleteFile(task.mOutFile);
} finally {
synchronized (mBinder) {
if (mProgressCallback != null) {
mProgressCallback.onDownloadComplete(task.mOutFile, task.mMimeType,
task.mId, downloadSuccess);
}
}
if (connection instanceof HttpURLConnection) {
((HttpURLConnection) connection).disconnect();
}
}
}
private void updateNotification(@NonNull NotificationManager nm, int eventId,
String mimeType, @NonNull Notification notification) {
// Skip showing the download notification for the directly open mime types.
if (eventId == NOTE_DOWNLOAD_DONE && isDirectlyOpenType(mimeType)) {
return;
}
nm.notify(eventId, notification);
}
/**
* 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];
long allRead = 0L;
final long maxRead = contentLength == CONTENT_LENGTH_UNKNOWN
? Long.MAX_VALUE : contentLength;
final boolean isDirectlyOpenType = isDirectlyOpenType(task.mMimeType);
final int maxDirectlyOpenLen = Objects.requireNonNullElse(
sDirectlyOpenMimeType.get(task.mMimeType), Integer.MAX_VALUE);
int lastProgress = -1;
long lastUpdateTime = -1L;
while (allRead < maxRead) {
if (task.mId <= mMaxCancelDownloadId) {
return false;
}
if (isDirectlyOpenType && allRead > maxDirectlyOpenLen) {
notifyDownloadAborted(task.mId, task.mMimeType,
DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE);
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 notifyDownloadAborted(int dlId, String mimeType, @AbortedReason int reason) {
Log.d(TAG, "Abort downloading the " + mimeType
+ " type file because of reason(" + reason + ")");
synchronized (mBinder) {
if (mProgressCallback != null) {
mProgressCallback.onDownloadAborted(dlId, reason);
}
}
}
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);
updateNotification(nm, NOTE_DOWNLOAD_PROGRESS, task.mMimeType, note);
return true;
}
}
static boolean isDirectlyOpenType(String type) {
return sDirectlyOpenMimeType.get(type) != null;
}
@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, PendingIntent.FLAG_IMMUTABLE);
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();
}
}