| /* |
| * 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(); |
| } |
| } |