• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.captiveportallogin;
18 
19 import static java.lang.Math.min;
20 
21 import android.app.Notification;
22 import android.app.NotificationChannel;
23 import android.app.NotificationManager;
24 import android.app.PendingIntent;
25 import android.app.Service;
26 import android.content.Context;
27 import android.content.Intent;
28 import android.content.res.Resources;
29 import android.graphics.drawable.Icon;
30 import android.icu.text.NumberFormat;
31 import android.net.Network;
32 import android.net.Uri;
33 import android.os.Binder;
34 import android.os.IBinder;
35 import android.os.ParcelFileDescriptor;
36 import android.provider.DocumentsContract;
37 import android.util.Log;
38 
39 import androidx.annotation.GuardedBy;
40 import androidx.annotation.IntDef;
41 import androidx.annotation.NonNull;
42 import androidx.annotation.Nullable;
43 import androidx.annotation.VisibleForTesting;
44 
45 import java.io.FileNotFoundException;
46 import java.io.FileOutputStream;
47 import java.io.IOException;
48 import java.io.InputStream;
49 import java.lang.annotation.Retention;
50 import java.lang.annotation.RetentionPolicy;
51 import java.net.HttpURLConnection;
52 import java.net.URL;
53 import java.net.URLConnection;
54 import java.util.HashMap;
55 import java.util.LinkedList;
56 import java.util.Objects;
57 import java.util.Queue;
58 import java.util.concurrent.atomic.AtomicInteger;
59 
60 /**
61  * Foreground {@link Service} that can be used to download files from a specific {@link Network}.
62  *
63  * If the network is or becomes unusable, the download will fail: the service will not attempt
64  * downloading from other networks on the device.
65  */
66 public class DownloadService extends Service {
67     private static final String TAG = DownloadService.class.getSimpleName();
68 
69     @VisibleForTesting
70     static final String ARG_CANCEL = "cancel";
71 
72     private static final String CHANNEL_DOWNLOADS = "downloads";
73     private static final String CHANNEL_DOWNLOAD_PROGRESS = "downloads_progress";
74     private static final int NOTE_DOWNLOAD_PROGRESS = 1;
75     private static final int NOTE_DOWNLOAD_DONE = 2;
76 
77     private static final int CONNECTION_TIMEOUT_MS = 30_000;
78     // Update download progress up to twice/sec.
79     private static final long MAX_PROGRESS_UPDATE_RATE_MS = 500L;
80     private static final long CONTENT_LENGTH_UNKNOWN = -1L;
81 
82     static final int DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE = 1;
83     @IntDef(value = { DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE })
84     @Retention(RetentionPolicy.SOURCE)
85     public @interface AbortedReason {}
86 
87     // All download job IDs <= this value should be cancelled
88     private volatile int mMaxCancelDownloadId;
89     @GuardedBy("mQueue")
90     private final Queue<DownloadTask> mQueue = new LinkedList<>();
91     @GuardedBy("mQueue")
92     private boolean mProcessing = false;
93 
94     @Nullable
95     @GuardedBy("mBinder")
96     private ProgressCallback mProgressCallback;
97     @NonNull
98     private final DownloadServiceBinder mBinder = new DownloadServiceBinder();
99     // Tracker for the ID to assign to the next download. The service startId is not used because it
100     // is not guaranteed to be monotonically increasing; increasing download IDs are convenient to
101     // allow cancelling current downloads when the user tapped the cancel button, but not subsequent
102     // download jobs.
103     private final AtomicInteger mNextDownloadId = new AtomicInteger(1);
104 
105     // Key is the directly open MIME type with an int as it max length bytes. The value is an int is
106     // enough since it's no point if > 2**31.
107     private static final HashMap<String, Integer> sDirectlyOpenMimeType =
108             new HashMap<String, Integer>();
109     static {
110         sDirectlyOpenMimeType.put("application/x-wifi-config", 100_000);
111     }
112 
113     private static class DownloadTask {
114         private final int mId;
115         private final Network mNetwork;
116         private final String mUserAgent;
117         private final String mUrl;
118         private final String mDisplayName;
119         private final Uri mOutFile;
120         private final String mMimeType;
121         private final Notification.Builder mCachedNotificationBuilder;
122 
DownloadTask(int id, Network network, String userAgent, String url, String displayName, Uri outFile, Context context, String mimeType)123         private DownloadTask(int id, Network network, String userAgent, String url,
124                 String displayName, Uri outFile, Context context, String mimeType) {
125             this.mId = id;
126             this.mNetwork = network;
127             this.mUserAgent = userAgent;
128             this.mUrl = url;
129             this.mDisplayName = displayName;
130             this.mOutFile = outFile;
131             this.mMimeType = mimeType;
132             final Resources res = context.getResources();
133             final Intent cancelIntent = new Intent(context, DownloadService.class)
134                     .putExtra(ARG_CANCEL, mId)
135                     .setIdentifier(String.valueOf(mId));
136 
137             final PendingIntent pendingIntent = PendingIntent.getService(context,
138                     0 /* requestCode */, cancelIntent, PendingIntent.FLAG_IMMUTABLE);
139             final Notification.Action cancelAction = new Notification.Action.Builder(
140                     Icon.createWithResource(context, R.drawable.ic_close),
141                     res.getString(android.R.string.cancel),
142                     pendingIntent).build();
143             this.mCachedNotificationBuilder = new Notification.Builder(
144                     context, CHANNEL_DOWNLOAD_PROGRESS)
145                     .setContentTitle(res.getString(R.string.downloading_paramfile, mDisplayName))
146                     .setSmallIcon(R.drawable.ic_cloud_download)
147                     .setOnlyAlertOnce(true)
148                     .addAction(cancelAction);
149         }
150     }
151 
152     /**
153      * Create an intent to be used via {android.app.Activity#startActivityForResult} to create
154      * an output file that can be used to start a download.
155      *
156      * <p>This creates a {@link Intent#ACTION_CREATE_DOCUMENT} intent. Its result must be handled by
157      * the calling activity.
158      */
makeCreateFileIntent(String mimetype, String filename)159     public static Intent makeCreateFileIntent(String mimetype, String filename) {
160         final Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
161         intent.addCategory(Intent.CATEGORY_OPENABLE);
162         intent.setType(mimetype);
163         intent.putExtra(Intent.EXTRA_TITLE, filename);
164 
165         return intent;
166     }
167 
168     @Override
onCreate()169     public void onCreate() {
170         createNotificationChannels();
171     }
172 
173     /**
174      * Called when the service needs to process a new command:
175      *  - If the intent has ARG_CANCEL extra, all downloads with a download ID <= that argument
176      *    should be cancelled.
177      *  - Otherwise the intent indicates a new download (with network, useragent, url... args).
178      *
179      * This method may be called multiple times if the user selects multiple files to download.
180      * Files will be queued to be downloaded one by one; if the user cancels the current file, this
181      * will not affect the next files that are queued.
182      */
183     @Override
onStartCommand(@ullable Intent intent, int flags, int startId)184     public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
185         if (intent == null) {
186             return START_NOT_STICKY;
187         }
188         final int cancelDownloadId = intent.getIntExtra(ARG_CANCEL, -1);
189         if (cancelDownloadId != -1) {
190             mMaxCancelDownloadId = cancelDownloadId;
191             return START_NOT_STICKY;
192         }
193         // If the service is killed the download is lost, which is fine because it is unlikely for a
194         // foreground service to be killed, and there is no easy way to know whether the download
195         // was really not yet completed if the service is restarted with e.g. START_REDELIVER_INTENT
196         return START_NOT_STICKY;
197     }
198 
enqueueDownloadTask(Network network, String userAgent, String url, String filename, Uri outFile, Context context, String mimeType)199     private int enqueueDownloadTask(Network network, String userAgent, String url, String filename,
200             Uri outFile, Context context, String mimeType) {
201         final DownloadTask task = new DownloadTask(mNextDownloadId.getAndIncrement(),
202                 network.getPrivateDnsBypassingCopy(), userAgent, url, filename, outFile,
203                 context, mimeType);
204         synchronized (mQueue) {
205             mQueue.add(task);
206             if (!mProcessing) {
207                 startForeground(NOTE_DOWNLOAD_PROGRESS, makeProgressNotification(task,
208                         null /* progress */));
209                 new Thread(new ProcessingRunnable()).start();
210             }
211             mProcessing = true;
212         }
213         return task.mId;
214     }
215 
createNotificationChannels()216     private void createNotificationChannels() {
217         final NotificationManager nm = getSystemService(NotificationManager.class);
218         final Resources res = getResources();
219         final NotificationChannel downloadChannel = new NotificationChannel(CHANNEL_DOWNLOADS,
220                 res.getString(R.string.channel_name_downloads),
221                 NotificationManager.IMPORTANCE_DEFAULT);
222         downloadChannel.setDescription(res.getString(R.string.channel_description_downloads));
223         nm.createNotificationChannel(downloadChannel);
224 
225         final NotificationChannel progressChannel = new NotificationChannel(
226                 CHANNEL_DOWNLOAD_PROGRESS,
227                 res.getString(R.string.channel_name_download_progress),
228                 NotificationManager.IMPORTANCE_LOW);
229         progressChannel.setDescription(
230                 res.getString(R.string.channel_description_download_progress));
231         nm.createNotificationChannel(progressChannel);
232     }
233 
234     @Override
onBind(Intent intent)235     public IBinder onBind(Intent intent) {
236         return mBinder;
237     }
238 
239     class DownloadServiceBinder extends Binder {
requestDownload(Network network, String userAgent, String url, String filename, Uri outFile, Context context, String mimeType)240         public int requestDownload(Network network, String userAgent, String url, String filename,
241                 Uri outFile, Context context, String mimeType) {
242             return enqueueDownloadTask(network, userAgent, url, filename, outFile, context,
243                     mimeType);
244         }
245 
cancelTask(int taskId)246         public void cancelTask(int taskId) {
247             synchronized (mQueue) {
248                 // If the task is no longer in the queue, it mean the download is in progress or
249                 // already completed. Set the cancel id to this requested id.
250                 if (!mQueue.removeIf(e -> e.mId == taskId)) {
251                     mMaxCancelDownloadId = taskId;
252                 }
253             }
254         }
255 
setProgressCallback(ProgressCallback callback)256         public void setProgressCallback(ProgressCallback callback) {
257             synchronized (mBinder) {
258                 mProgressCallback = callback;
259             }
260         }
261     }
262 
263     /**
264      * Callback for notifying the download progress change.
265      */
266     interface ProgressCallback {
267         /** Notify the requested download task is completed. */
onDownloadComplete(@onNull Uri inputFile, @NonNull String mimeType, int downloadId, boolean success)268         void onDownloadComplete(@NonNull Uri inputFile, @NonNull String mimeType, int downloadId,
269                 boolean success);
270         /** Notify the requested download task is aborted. */
onDownloadAborted(int downloadId, @AbortedReason int reason)271         void onDownloadAborted(int downloadId, @AbortedReason int reason);
272     }
273 
274     private class ProcessingRunnable implements Runnable {
275         @Override
run()276         public void run() {
277             while (true) {
278                 final DownloadTask task;
279                 synchronized (mQueue) {
280                     task = mQueue.poll();
281                     if (task == null)  {
282                         mProcessing = false;
283                         stopForeground(true /* removeNotification */);
284                         return;
285                     }
286                 }
287 
288                 processDownload(task);
289             }
290         }
291 
processDownload(@onNull final DownloadTask task)292         private void processDownload(@NonNull final DownloadTask task) {
293             final NotificationManager nm = getSystemService(NotificationManager.class);
294             // Start by showing an indeterminate progress notification
295             updateNotification(nm, NOTE_DOWNLOAD_PROGRESS, task.mMimeType,
296                     makeProgressNotification(task, null /* progress */));
297             URLConnection connection = null;
298             boolean downloadSuccess = false;
299             try {
300                 final URL url = new URL(task.mUrl);
301 
302                 // This may fail if the network is not usable anymore, which is the expected
303                 // behavior: the download should fail if it cannot be completed on the assigned
304                 // network.
305                 connection = task.mNetwork.openConnection(url);
306                 connection.setConnectTimeout(CONNECTION_TIMEOUT_MS);
307                 connection.setReadTimeout(CONNECTION_TIMEOUT_MS);
308                 connection.setRequestProperty("User-Agent", task.mUserAgent);
309 
310                 long contentLength = CONTENT_LENGTH_UNKNOWN;
311                 if (connection instanceof HttpURLConnection) {
312                     final HttpURLConnection httpConn = (HttpURLConnection) connection;
313                     final int responseCode = httpConn.getResponseCode();
314                     if (responseCode < 200 || responseCode > 299) {
315                         throw new IOException("Download error: response code " + responseCode);
316                     }
317 
318                     contentLength = httpConn.getContentLengthLong();
319                 }
320 
321                 try (ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(
322                         task.mOutFile, "rwt");
323                      FileOutputStream fop = new FileOutputStream(pfd.getFileDescriptor())) {
324                     final InputStream is = connection.getInputStream();
325 
326                     if (!downloadToFile(is, fop, contentLength, task, nm)) {
327                         // Download cancelled
328                         tryDeleteFile(task.mOutFile);
329                         // Don't clear the notification: this will be done when the service stops
330                         // (foreground service notifications cannot be cleared).
331                         return;
332                     }
333                 }
334 
335                 downloadSuccess = true;
336                 updateNotification(nm, NOTE_DOWNLOAD_DONE, task.mMimeType,
337                         makeDoneNotification(task.mId, task.mDisplayName, task.mOutFile));
338             } catch (IOException e) {
339                 Log.e(DownloadService.class.getSimpleName(), "Download error", e);
340                 updateNotification(nm, NOTE_DOWNLOAD_DONE, task.mMimeType,
341                         makeErrorNotification(task.mDisplayName));
342                 tryDeleteFile(task.mOutFile);
343             } finally {
344                 synchronized (mBinder) {
345                     if (mProgressCallback != null) {
346                         mProgressCallback.onDownloadComplete(task.mOutFile, task.mMimeType,
347                                 task.mId, downloadSuccess);
348                     }
349                 }
350                 if (connection instanceof HttpURLConnection) {
351                     ((HttpURLConnection) connection).disconnect();
352                 }
353             }
354         }
355 
updateNotification(@onNull NotificationManager nm, int eventId, String mimeType, @NonNull Notification notification)356         private void updateNotification(@NonNull NotificationManager nm, int eventId,
357                 String mimeType, @NonNull Notification notification) {
358             // Skip showing the download notification for the directly open mime types.
359             if (eventId == NOTE_DOWNLOAD_DONE && isDirectlyOpenType(mimeType)) {
360                 return;
361             }
362             nm.notify(eventId, notification);
363         }
364 
365         /**
366          * Download the contents of an {@link InputStream} to a {@link FileOutputStream}, and
367          * updates the progress notification.
368          * @return True if download is completed, false if cancelled
369          */
downloadToFile(@onNull InputStream is, @NonNull FileOutputStream fop, long contentLength, @NonNull DownloadTask task, @NonNull NotificationManager nm)370         private boolean downloadToFile(@NonNull InputStream is, @NonNull FileOutputStream fop,
371                 long contentLength, @NonNull DownloadTask task,
372                 @NonNull NotificationManager nm) throws IOException {
373             final byte[] buffer = new byte[1500];
374             long allRead = 0L;
375             final long maxRead = contentLength == CONTENT_LENGTH_UNKNOWN
376                     ? Long.MAX_VALUE : contentLength;
377             final boolean isDirectlyOpenType = isDirectlyOpenType(task.mMimeType);
378             final int maxDirectlyOpenLen = Objects.requireNonNullElse(
379                     sDirectlyOpenMimeType.get(task.mMimeType), Integer.MAX_VALUE);
380             int lastProgress = -1;
381             long lastUpdateTime = -1L;
382             while (allRead < maxRead) {
383                 if (task.mId <= mMaxCancelDownloadId) {
384                     return false;
385                 }
386                 if (isDirectlyOpenType && allRead > maxDirectlyOpenLen) {
387                     notifyDownloadAborted(task.mId, task.mMimeType,
388                             DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE);
389                     return false;
390                 }
391 
392                 final int read = is.read(buffer, 0, (int) min(buffer.length, maxRead - allRead));
393                 if (read < 0) {
394                     // End of stream
395                     break;
396                 }
397 
398                 allRead += read;
399                 fop.write(buffer, 0, read);
400 
401                 final Integer progress = getProgress(contentLength, allRead);
402                 if (progress == null || progress.equals(lastProgress)) continue;
403 
404                 final long now = System.currentTimeMillis();
405                 if (maybeNotifyProgress(progress, lastProgress, now, lastUpdateTime, task, nm)) {
406                     lastUpdateTime = now;
407                 }
408                 lastProgress = progress;
409             }
410             return true;
411         }
412 
notifyDownloadAborted(int dlId, String mimeType, @AbortedReason int reason)413         private void notifyDownloadAborted(int dlId, String mimeType, @AbortedReason int reason) {
414             Log.d(TAG, "Abort downloading the " + mimeType
415                     + " type file because of reason(" + reason + ")");
416             synchronized (mBinder) {
417                 if (mProgressCallback != null) {
418                     mProgressCallback.onDownloadAborted(dlId, reason);
419                 }
420             }
421         }
422 
tryDeleteFile(@onNull Uri file)423         private void tryDeleteFile(@NonNull Uri file) {
424             try {
425                 // The file was not created by the DownloadService, however because the service
426                 // is only usable from this application, and the file should be created from this
427                 // same application, the content resolver should be the same.
428                 DocumentsContract.deleteDocument(getContentResolver(), file);
429             } catch (FileNotFoundException e) {
430                 // Nothing to delete
431             }
432         }
433 
getProgress(long contentLength, long totalRead)434         private Integer getProgress(long contentLength, long totalRead) {
435             if (contentLength == CONTENT_LENGTH_UNKNOWN || contentLength == 0) return null;
436             return (int) (totalRead * 100 / contentLength);
437         }
438 
439         /**
440          * Update the progress notification, if it was not updated recently.
441          * @return True if progress was updated.
442          */
maybeNotifyProgress(int progress, int lastProgress, long now, long lastProgressUpdateTimeMs, @NonNull DownloadTask task, @NonNull NotificationManager nm)443         private boolean maybeNotifyProgress(int progress, int lastProgress, long now,
444                 long lastProgressUpdateTimeMs, @NonNull DownloadTask task,
445                 @NonNull NotificationManager nm) {
446             if (lastProgress > 0 && progress < 100
447                     && lastProgressUpdateTimeMs > 0
448                     && now - lastProgressUpdateTimeMs < MAX_PROGRESS_UPDATE_RATE_MS) {
449                 // Rate-limit intermediate progress updates: NotificationManager will start ignoring
450                 // notifications from the current process if too many updates are posted too fast.
451                 // The shown progress will not "lag behind" much in most cases. An alternative
452                 // would be to delay the progress update to rate-limit, but this would bring
453                 // synchronization problems.
454                 return false;
455             }
456             final Notification note = makeProgressNotification(task, progress);
457             updateNotification(nm, NOTE_DOWNLOAD_PROGRESS, task.mMimeType, note);
458 
459             return true;
460         }
461     }
462 
isDirectlyOpenType(String type)463     static boolean isDirectlyOpenType(String type) {
464         return sDirectlyOpenMimeType.get(type) != null;
465     }
466 
467     @NonNull
makeProgressNotification(@onNull DownloadTask task, @Nullable Integer progress)468     private Notification makeProgressNotification(@NonNull DownloadTask task,
469             @Nullable Integer progress) {
470         return task.mCachedNotificationBuilder
471                 .setContentText(progress == null
472                         ? null
473                         : NumberFormat.getPercentInstance().format(progress.floatValue() / 100))
474                 .setProgress(100,
475                         progress == null ? 0 : progress,
476                         progress == null /* indeterminate */)
477                 .build();
478     }
479 
480     @NonNull
makeDoneNotification(int taskId, @NonNull String displayName, @NonNull Uri outFile)481     private Notification makeDoneNotification(int taskId, @NonNull String displayName,
482             @NonNull Uri outFile) {
483         final Intent intent = new Intent(Intent.ACTION_VIEW)
484                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
485                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
486                 .setData(outFile)
487                 .setIdentifier(String.valueOf(taskId));
488 
489         final PendingIntent pendingIntent = PendingIntent.getActivity(
490                 this, 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE);
491         return new Notification.Builder(this, CHANNEL_DOWNLOADS)
492                 .setContentTitle(getResources().getString(R.string.download_completed))
493                 .setContentText(displayName)
494                 .setSmallIcon(R.drawable.ic_cloud_download)
495                 .setContentIntent(pendingIntent)
496                 .setAutoCancel(true)
497                 .build();
498     }
499 
500     @NonNull
makeErrorNotification(@onNull String filename)501     private Notification makeErrorNotification(@NonNull String filename) {
502         final Resources res = getResources();
503         return new Notification.Builder(this, CHANNEL_DOWNLOADS)
504                 .setContentTitle(res.getString(R.string.error_downloading_paramfile, filename))
505                 .setSmallIcon(R.drawable.ic_cloud_download)
506                 .build();
507     }
508 }
509