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     // The class needs to be at least protected for Mockito to create mocks
240     @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
241     protected class DownloadServiceBinder extends Binder {
requestDownload(Network network, String userAgent, String url, String filename, Uri outFile, Context context, String mimeType)242         public int requestDownload(Network network, String userAgent, String url, String filename,
243                 Uri outFile, Context context, String mimeType) {
244             return enqueueDownloadTask(network, userAgent, url, filename, outFile, context,
245                     mimeType);
246         }
247 
cancelTask(int taskId)248         public void cancelTask(int taskId) {
249             synchronized (mQueue) {
250                 // If the task is no longer in the queue, it mean the download is in progress or
251                 // already completed. Set the cancel id to this requested id.
252                 if (!mQueue.removeIf(e -> e.mId == taskId)) {
253                     mMaxCancelDownloadId = taskId;
254                 }
255             }
256         }
257 
setProgressCallback(ProgressCallback callback)258         public void setProgressCallback(ProgressCallback callback) {
259             synchronized (mBinder) {
260                 mProgressCallback = callback;
261             }
262         }
263     }
264 
265     /**
266      * Callback for notifying the download progress change.
267      */
268     interface ProgressCallback {
269         /** Notify the requested download task is completed. */
onDownloadComplete(@onNull Uri inputFile, @NonNull String mimeType, int downloadId, boolean success)270         void onDownloadComplete(@NonNull Uri inputFile, @NonNull String mimeType, int downloadId,
271                 boolean success);
272         /** Notify the requested download task is aborted. */
onDownloadAborted(int downloadId, @AbortedReason int reason)273         void onDownloadAborted(int downloadId, @AbortedReason int reason);
274     }
275 
276     private class ProcessingRunnable implements Runnable {
277         @Override
run()278         public void run() {
279             while (true) {
280                 final DownloadTask task;
281                 synchronized (mQueue) {
282                     task = mQueue.poll();
283                     if (task == null)  {
284                         mProcessing = false;
285                         stopForeground(true /* removeNotification */);
286                         return;
287                     }
288                 }
289 
290                 processDownload(task);
291             }
292         }
293 
processDownload(@onNull final DownloadTask task)294         private void processDownload(@NonNull final DownloadTask task) {
295             final NotificationManager nm = getSystemService(NotificationManager.class);
296             // Start by showing an indeterminate progress notification
297             updateNotification(nm, NOTE_DOWNLOAD_PROGRESS, task.mMimeType,
298                     makeProgressNotification(task, null /* progress */));
299             URLConnection connection = null;
300             boolean downloadSuccess = false;
301             try {
302                 final URL url = new URL(task.mUrl);
303 
304                 // This may fail if the network is not usable anymore, which is the expected
305                 // behavior: the download should fail if it cannot be completed on the assigned
306                 // network.
307                 connection = task.mNetwork.openConnection(url);
308                 connection.setConnectTimeout(CONNECTION_TIMEOUT_MS);
309                 connection.setReadTimeout(CONNECTION_TIMEOUT_MS);
310                 connection.setRequestProperty("User-Agent", task.mUserAgent);
311 
312                 long contentLength = CONTENT_LENGTH_UNKNOWN;
313                 if (connection instanceof HttpURLConnection) {
314                     final HttpURLConnection httpConn = (HttpURLConnection) connection;
315                     final int responseCode = httpConn.getResponseCode();
316                     if (responseCode < 200 || responseCode > 299) {
317                         throw new IOException("Download error: response code " + responseCode);
318                     }
319 
320                     contentLength = httpConn.getContentLengthLong();
321                 }
322 
323                 try (ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(
324                         task.mOutFile, "rwt");
325                      FileOutputStream fop = new FileOutputStream(pfd.getFileDescriptor())) {
326                     final InputStream is = connection.getInputStream();
327 
328                     if (!downloadToFile(is, fop, contentLength, task, nm)) {
329                         // Download cancelled
330                         tryDeleteFile(task.mOutFile);
331                         // Don't clear the notification: this will be done when the service stops
332                         // (foreground service notifications cannot be cleared).
333                         return;
334                     }
335                 }
336 
337                 downloadSuccess = true;
338                 updateNotification(nm, NOTE_DOWNLOAD_DONE, task.mMimeType,
339                         makeDoneNotification(task));
340             } catch (IOException e) {
341                 Log.e(DownloadService.class.getSimpleName(), "Download error", e);
342                 updateNotification(nm, NOTE_DOWNLOAD_DONE, task.mMimeType,
343                         makeErrorNotification(task.mDisplayName));
344                 tryDeleteFile(task.mOutFile);
345             } finally {
346                 synchronized (mBinder) {
347                     if (mProgressCallback != null) {
348                         mProgressCallback.onDownloadComplete(task.mOutFile, task.mMimeType,
349                                 task.mId, downloadSuccess);
350                     }
351                 }
352                 if (connection instanceof HttpURLConnection) {
353                     ((HttpURLConnection) connection).disconnect();
354                 }
355             }
356         }
357 
updateNotification(@onNull NotificationManager nm, int eventId, String mimeType, @NonNull Notification notification)358         private void updateNotification(@NonNull NotificationManager nm, int eventId,
359                 String mimeType, @NonNull Notification notification) {
360             // Skip showing the download notification for the directly open mime types.
361             if (eventId == NOTE_DOWNLOAD_DONE && isDirectlyOpenType(mimeType)) {
362                 return;
363             }
364             nm.notify(eventId, notification);
365         }
366 
367         /**
368          * Download the contents of an {@link InputStream} to a {@link FileOutputStream}, and
369          * updates the progress notification.
370          * @return True if download is completed, false if cancelled
371          */
downloadToFile(@onNull InputStream is, @NonNull FileOutputStream fop, long contentLength, @NonNull DownloadTask task, @NonNull NotificationManager nm)372         private boolean downloadToFile(@NonNull InputStream is, @NonNull FileOutputStream fop,
373                 long contentLength, @NonNull DownloadTask task,
374                 @NonNull NotificationManager nm) throws IOException {
375             final byte[] buffer = new byte[1500];
376             long allRead = 0L;
377             final long maxRead = contentLength == CONTENT_LENGTH_UNKNOWN
378                     ? Long.MAX_VALUE : contentLength;
379             final boolean isDirectlyOpenType = isDirectlyOpenType(task.mMimeType);
380             final int maxDirectlyOpenLen = Objects.requireNonNullElse(
381                     sDirectlyOpenMimeType.get(task.mMimeType), Integer.MAX_VALUE);
382             int lastProgress = -1;
383             long lastUpdateTime = -1L;
384             while (allRead < maxRead) {
385                 if (task.mId <= mMaxCancelDownloadId) {
386                     return false;
387                 }
388                 if (isDirectlyOpenType && allRead > maxDirectlyOpenLen) {
389                     notifyDownloadAborted(task.mId, task.mMimeType,
390                             DOWNLOAD_ABORTED_REASON_FILE_TOO_LARGE);
391                     return false;
392                 }
393 
394                 final int read = is.read(buffer, 0, (int) min(buffer.length, maxRead - allRead));
395                 if (read < 0) {
396                     // End of stream
397                     break;
398                 }
399 
400                 allRead += read;
401                 fop.write(buffer, 0, read);
402 
403                 final Integer progress = getProgress(contentLength, allRead);
404                 if (progress == null || progress.equals(lastProgress)) continue;
405 
406                 final long now = System.currentTimeMillis();
407                 if (maybeNotifyProgress(progress, lastProgress, now, lastUpdateTime, task, nm)) {
408                     lastUpdateTime = now;
409                 }
410                 lastProgress = progress;
411             }
412             return true;
413         }
414 
notifyDownloadAborted(int dlId, String mimeType, @AbortedReason int reason)415         private void notifyDownloadAborted(int dlId, String mimeType, @AbortedReason int reason) {
416             Log.d(TAG, "Abort downloading the " + mimeType
417                     + " type file because of reason(" + reason + ")");
418             synchronized (mBinder) {
419                 if (mProgressCallback != null) {
420                     mProgressCallback.onDownloadAborted(dlId, reason);
421                 }
422             }
423         }
424 
tryDeleteFile(@onNull Uri file)425         private void tryDeleteFile(@NonNull Uri file) {
426             try {
427                 // The file was not created by the DownloadService, however because the service
428                 // is only usable from this application, and the file should be created from this
429                 // same application, the content resolver should be the same.
430                 DocumentsContract.deleteDocument(getContentResolver(), file);
431             } catch (FileNotFoundException e) {
432                 // Nothing to delete
433             }
434         }
435 
getProgress(long contentLength, long totalRead)436         private Integer getProgress(long contentLength, long totalRead) {
437             if (contentLength == CONTENT_LENGTH_UNKNOWN || contentLength == 0) return null;
438             return (int) (totalRead * 100 / contentLength);
439         }
440 
441         /**
442          * Update the progress notification, if it was not updated recently.
443          * @return True if progress was updated.
444          */
maybeNotifyProgress(int progress, int lastProgress, long now, long lastProgressUpdateTimeMs, @NonNull DownloadTask task, @NonNull NotificationManager nm)445         private boolean maybeNotifyProgress(int progress, int lastProgress, long now,
446                 long lastProgressUpdateTimeMs, @NonNull DownloadTask task,
447                 @NonNull NotificationManager nm) {
448             if (lastProgress > 0 && progress < 100
449                     && lastProgressUpdateTimeMs > 0
450                     && now - lastProgressUpdateTimeMs < MAX_PROGRESS_UPDATE_RATE_MS) {
451                 // Rate-limit intermediate progress updates: NotificationManager will start ignoring
452                 // notifications from the current process if too many updates are posted too fast.
453                 // The shown progress will not "lag behind" much in most cases. An alternative
454                 // would be to delay the progress update to rate-limit, but this would bring
455                 // synchronization problems.
456                 return false;
457             }
458             final Notification note = makeProgressNotification(task, progress);
459             updateNotification(nm, NOTE_DOWNLOAD_PROGRESS, task.mMimeType, note);
460 
461             return true;
462         }
463     }
464 
isDirectlyOpenType(String type)465     static boolean isDirectlyOpenType(String type) {
466         return sDirectlyOpenMimeType.get(type) != null;
467     }
468 
469     @NonNull
makeProgressNotification(@onNull DownloadTask task, @Nullable Integer progress)470     private Notification makeProgressNotification(@NonNull DownloadTask task,
471             @Nullable Integer progress) {
472         return task.mCachedNotificationBuilder
473                 .setContentText(progress == null
474                         ? null
475                         : NumberFormat.getPercentInstance().format(progress.floatValue() / 100))
476                 .setProgress(100,
477                         progress == null ? 0 : progress,
478                         progress == null /* indeterminate */)
479                 .build();
480     }
481 
482     @NonNull
makeDoneNotification(@onNull DownloadTask task)483     private Notification makeDoneNotification(@NonNull DownloadTask task) {
484         final Intent intent = new Intent(Intent.ACTION_VIEW)
485                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
486                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
487                 .setDataAndType(task.mOutFile, task.mMimeType)
488                 .setIdentifier(String.valueOf(task.mId));
489 
490         final PendingIntent pendingIntent = PendingIntent.getActivity(
491                 this, 0 /* requestCode */, intent, PendingIntent.FLAG_IMMUTABLE);
492         return new Notification.Builder(this, CHANNEL_DOWNLOADS)
493                 .setContentTitle(getResources().getString(R.string.download_completed))
494                 .setContentText(task.mDisplayName)
495                 .setSmallIcon(R.drawable.ic_cloud_download)
496                 .setContentIntent(pendingIntent)
497                 .setAutoCancel(true)
498                 .build();
499     }
500 
501     @NonNull
makeErrorNotification(@onNull String filename)502     private Notification makeErrorNotification(@NonNull String filename) {
503         final Resources res = getResources();
504         return new Notification.Builder(this, CHANNEL_DOWNLOADS)
505                 .setContentTitle(res.getString(R.string.error_downloading_paramfile, filename))
506                 .setSmallIcon(R.drawable.ic_cloud_download)
507                 .build();
508     }
509 }
510