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.IBinder;
34 import android.os.ParcelFileDescriptor;
35 import android.provider.DocumentsContract;
36 import android.util.Log;
37 
38 import androidx.annotation.GuardedBy;
39 import androidx.annotation.NonNull;
40 import androidx.annotation.Nullable;
41 import androidx.annotation.VisibleForTesting;
42 
43 import java.io.FileNotFoundException;
44 import java.io.FileOutputStream;
45 import java.io.IOException;
46 import java.io.InputStream;
47 import java.net.HttpURLConnection;
48 import java.net.URL;
49 import java.net.URLConnection;
50 import java.util.ArrayDeque;
51 import java.util.Queue;
52 import java.util.concurrent.atomic.AtomicInteger;
53 
54 /**
55  * Foreground {@link Service} that can be used to download files from a specific {@link Network}.
56  *
57  * If the network is or becomes unusable, the download will fail: the service will not attempt
58  * downloading from other networks on the device.
59  */
60 public class DownloadService extends Service {
61     private static final String TAG = DownloadService.class.getSimpleName();
62 
63     @VisibleForTesting
64     static final String ARG_NETWORK = "network";
65     @VisibleForTesting
66     static final String ARG_USERAGENT = "useragent";
67     @VisibleForTesting
68     static final String ARG_URL = "url";
69     @VisibleForTesting
70     static final String ARG_DISPLAY_NAME = "displayname";
71     @VisibleForTesting
72     static final String ARG_OUTFILE = "outfile";
73 
74     private static final String ARG_CANCEL = "cancel";
75 
76     private static final String CHANNEL_DOWNLOADS = "downloads";
77     private static final String CHANNEL_DOWNLOAD_PROGRESS = "downloads_progress";
78     private static final int NOTE_DOWNLOAD_PROGRESS = 1;
79     private static final int NOTE_DOWNLOAD_DONE = 2;
80 
81     private static final int CONNECTION_TIMEOUT_MS = 30_000;
82     // Update download progress up to twice/sec.
83     private static final long MAX_PROGRESS_UPDATE_RATE_MS = 500L;
84     private static final long CONTENT_LENGTH_UNKNOWN = -1L;
85 
86     // All download job IDs <= this value should be cancelled
87     private volatile int mMaxCancelDownloadId;
88 
89     @GuardedBy("mQueue")
90     private final Queue<DownloadTask> mQueue = new ArrayDeque<>(1);
91     @GuardedBy("mQueue")
92     private boolean mProcessing = false;
93 
94     // Tracker for the ID to assign to the next download. The service startId is not used because it
95     // is not guaranteed to be monotonically increasing; increasing download IDs are convenient to
96     // allow cancelling current downloads when the user tapped the cancel button, but not subsequent
97     // download jobs.
98     private final AtomicInteger mNextDownloadId = new AtomicInteger(1);
99 
100     private static class DownloadTask {
101         private final int mId;
102         private final Network mNetwork;
103         private final String mUserAgent;
104         private final String mUrl;
105         private final String mDisplayName;
106         private final Uri mOutFile;
107 
108         private final Notification.Builder mCachedNotificationBuilder;
109 
DownloadTask(int id, Network network, String userAgent, String url, String displayName, Uri outFile, Context context)110         private DownloadTask(int id, Network network, String userAgent, String url,
111                 String displayName, Uri outFile, Context context) {
112             this.mId = id;
113             this.mNetwork = network;
114             this.mUserAgent = userAgent;
115             this.mUrl = url;
116             this.mDisplayName = displayName;
117             this.mOutFile = outFile;
118 
119             final Resources res = context.getResources();
120             final Intent cancelIntent = new Intent(context, DownloadService.class)
121                     .putExtra(ARG_CANCEL, mId)
122                     .setIdentifier(String.valueOf(mId));
123 
124             final PendingIntent pendingIntent = PendingIntent.getService(context,
125                     0 /* requestCode */, cancelIntent, 0 /* flags */);
126             final Notification.Action cancelAction = new Notification.Action.Builder(
127                     Icon.createWithResource(context, R.drawable.ic_close),
128                     res.getString(android.R.string.cancel),
129                     pendingIntent).build();
130             this.mCachedNotificationBuilder = new Notification.Builder(
131                     context, CHANNEL_DOWNLOAD_PROGRESS)
132                     .setContentTitle(res.getString(R.string.downloading_paramfile, mDisplayName))
133                     .setSmallIcon(R.drawable.ic_cloud_download)
134                     .setOnlyAlertOnce(true)
135                     .addAction(cancelAction);
136         }
137     }
138 
139     /**
140      * Create an intent to be used to start the service.
141      *
142      * <p>The intent can then be used with {@link Context#startForegroundService(Intent)}.
143      * @param packageContext Context to use to resolve the {@link DownloadService}.
144      * @param network Network that the download should be done on. No other network will be
145      *                considered for the download.
146      * @param userAgent UserAgent to use for the download request.
147      * @param url URL to download from.
148      * @param displayName Name of the downloaded file, to be used when displaying progress UI (does
149      *                    not affect the actual output file).
150      * @param outFile Output file of the download.
151      */
makeDownloadIntent(Context packageContext, Network network, String userAgent, String url, String displayName, Uri outFile)152     public static Intent makeDownloadIntent(Context packageContext, Network network,
153             String userAgent, String url, String displayName, Uri outFile) {
154         final Intent intent = new Intent(packageContext, DownloadService.class);
155         intent.putExtra(ARG_NETWORK, network);
156         intent.putExtra(ARG_USERAGENT, userAgent);
157         intent.putExtra(ARG_URL, url);
158         intent.putExtra(ARG_DISPLAY_NAME, displayName);
159         intent.putExtra(ARG_OUTFILE, outFile);
160         return intent;
161     }
162 
163     /**
164      * Create an intent to be used via {android.app.Activity#startActivityForResult} to create
165      * an output file that can be used to start a download.
166      *
167      * <p>This creates a {@link Intent#ACTION_CREATE_DOCUMENT} intent. Its result must be handled by
168      * the calling activity.
169      */
makeCreateFileIntent(String mimetype, String filename)170     public static Intent makeCreateFileIntent(String mimetype, String filename) {
171         final Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
172         intent.addCategory(Intent.CATEGORY_OPENABLE);
173         intent.setType(mimetype);
174         intent.putExtra(Intent.EXTRA_TITLE, filename);
175 
176         return intent;
177     }
178 
179     @Override
onCreate()180     public void onCreate() {
181         createNotificationChannels();
182     }
183 
184     /**
185      * Called when the service needs to process a new command:
186      *  - If the intent has ARG_CANCEL extra, all downloads with a download ID <= that argument
187      *    should be cancelled.
188      *  - Otherwise the intent indicates a new download (with network, useragent, url... args).
189      *
190      * This method may be called multiple times if the user selects multiple files to download.
191      * Files will be queued to be downloaded one by one; if the user cancels the current file, this
192      * will not affect the next files that are queued.
193      */
194     @Override
onStartCommand(@ullable Intent intent, int flags, int startId)195     public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
196         if (intent == null) {
197             return START_NOT_STICKY;
198         }
199         final int cancelDownloadId = intent.getIntExtra(ARG_CANCEL, -1);
200         if (cancelDownloadId != -1) {
201             mMaxCancelDownloadId = cancelDownloadId;
202             return START_NOT_STICKY;
203         }
204 
205         final Network network = intent.getParcelableExtra(ARG_NETWORK);
206         final String userAgent = intent.getStringExtra(ARG_USERAGENT);
207         final String url = intent.getStringExtra(ARG_URL);
208         final String filename = intent.getStringExtra(ARG_DISPLAY_NAME);
209         final Uri outFile = intent.getParcelableExtra(ARG_OUTFILE);
210 
211         if (network == null || userAgent == null || url == null || filename == null
212                 || outFile == null) {
213             Log.e(TAG, String.format("Missing parameters; network: %s, userAgent: %s, url: %s, "
214                     + "filename: %s, outFile: %s", network, userAgent, url, filename, outFile));
215             return START_NOT_STICKY;
216         }
217 
218         synchronized (mQueue) {
219             final DownloadTask task = new DownloadTask(mNextDownloadId.getAndIncrement(), network,
220                     userAgent, url, filename, outFile, this);
221             mQueue.add(task);
222             if (!mProcessing) {
223                 startForeground(NOTE_DOWNLOAD_PROGRESS, makeProgressNotification(task,
224                         null /* progress */));
225                 new Thread(new ProcessingRunnable()).start();
226             }
227             mProcessing = true;
228         }
229 
230         // If the service is killed the download is lost, which is fine because it is unlikely for a
231         // foreground service to be killed, and there is no easy way to know whether the download
232         // was really not yet completed if the service is restarted with e.g. START_REDELIVER_INTENT
233         return START_NOT_STICKY;
234     }
235 
createNotificationChannels()236     private void createNotificationChannels() {
237         final NotificationManager nm = getSystemService(NotificationManager.class);
238         final Resources res = getResources();
239         final NotificationChannel downloadChannel = new NotificationChannel(CHANNEL_DOWNLOADS,
240                 res.getString(R.string.channel_name_downloads),
241                 NotificationManager.IMPORTANCE_DEFAULT);
242         downloadChannel.setDescription(res.getString(R.string.channel_description_downloads));
243         nm.createNotificationChannel(downloadChannel);
244 
245         final NotificationChannel progressChannel = new NotificationChannel(
246                 CHANNEL_DOWNLOAD_PROGRESS,
247                 res.getString(R.string.channel_name_download_progress),
248                 NotificationManager.IMPORTANCE_LOW);
249         progressChannel.setDescription(
250                 res.getString(R.string.channel_description_download_progress));
251         nm.createNotificationChannel(progressChannel);
252     }
253 
254     @Override
onBind(Intent intent)255     public IBinder onBind(Intent intent) {
256         return null;
257     }
258 
259     private class ProcessingRunnable implements Runnable {
260         @Override
run()261         public void run() {
262             while (true) {
263                 final DownloadTask task;
264                 synchronized (mQueue) {
265                     task = mQueue.poll();
266                     if (task == null)  {
267                         mProcessing = false;
268                         stopForeground(true /* removeNotification */);
269                         return;
270                     }
271                 }
272 
273                 processDownload(task);
274             }
275         }
276 
processDownload(@onNull final DownloadTask task)277         private void processDownload(@NonNull final DownloadTask task) {
278             final NotificationManager nm = getSystemService(NotificationManager.class);
279             // Start by showing an indeterminate progress notification
280             nm.notify(NOTE_DOWNLOAD_PROGRESS, makeProgressNotification(task, null /* progress */));
281             URLConnection connection = null;
282             try {
283                 final URL url = new URL(task.mUrl);
284 
285                 // This may fail if the network is not usable anymore, which is the expected
286                 // behavior: the download should fail if it cannot be completed on the assigned
287                 // network.
288                 connection = task.mNetwork.openConnection(url);
289                 connection.setConnectTimeout(CONNECTION_TIMEOUT_MS);
290                 connection.setReadTimeout(CONNECTION_TIMEOUT_MS);
291                 connection.setRequestProperty("User-Agent", task.mUserAgent);
292 
293                 long contentLength = CONTENT_LENGTH_UNKNOWN;
294                 if (connection instanceof HttpURLConnection) {
295                     final HttpURLConnection httpConn = (HttpURLConnection) connection;
296                     final int responseCode = httpConn.getResponseCode();
297                     if (responseCode < 200 || responseCode > 299) {
298                         throw new IOException("Download error: response code " + responseCode);
299                     }
300 
301                     contentLength = httpConn.getContentLengthLong();
302                 }
303 
304                 try (ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(
305                         task.mOutFile, "rwt");
306                      FileOutputStream fop = new FileOutputStream(pfd.getFileDescriptor())) {
307                     final InputStream is = connection.getInputStream();
308 
309                     if (!downloadToFile(is, fop, contentLength, task, nm)) {
310                         // Download cancelled
311                         tryDeleteFile(task.mOutFile);
312                         // Don't clear the notification: this will be done when the service stops
313                         // (foreground service notifications cannot be cleared).
314                         return;
315                     }
316                 }
317 
318                 nm.notify(NOTE_DOWNLOAD_DONE,
319                         makeDoneNotification(task.mId, task.mDisplayName, task.mOutFile));
320             } catch (IOException e) {
321                 Log.e(DownloadService.class.getSimpleName(), "Download error", e);
322                 nm.notify(NOTE_DOWNLOAD_DONE, makeErrorNotification(task.mDisplayName));
323                 tryDeleteFile(task.mOutFile);
324             } finally {
325                 if (connection instanceof HttpURLConnection) {
326                     ((HttpURLConnection) connection).disconnect();
327                 }
328             }
329         }
330 
331         /**
332          * Download the contents of an {@link InputStream} to a {@link FileOutputStream}, and
333          * updates the progress notification.
334          * @return True if download is completed, false if cancelled
335          */
downloadToFile(@onNull InputStream is, @NonNull FileOutputStream fop, long contentLength, @NonNull DownloadTask task, @NonNull NotificationManager nm)336         private boolean downloadToFile(@NonNull InputStream is, @NonNull FileOutputStream fop,
337                 long contentLength, @NonNull DownloadTask task,
338                 @NonNull NotificationManager nm) throws IOException {
339             final byte[] buffer = new byte[1500];
340             int allRead = 0;
341             final long maxRead = contentLength == CONTENT_LENGTH_UNKNOWN
342                     ? Long.MAX_VALUE : contentLength;
343             int lastProgress = -1;
344             long lastUpdateTime = -1L;
345             while (allRead < maxRead) {
346                 if (task.mId <= mMaxCancelDownloadId) {
347                     return false;
348                 }
349 
350                 final int read = is.read(buffer, 0, (int) min(buffer.length, maxRead - allRead));
351                 if (read < 0) {
352                     // End of stream
353                     break;
354                 }
355 
356                 allRead += read;
357                 fop.write(buffer, 0, read);
358 
359                 final Integer progress = getProgress(contentLength, allRead);
360                 if (progress == null || progress.equals(lastProgress)) continue;
361 
362                 final long now = System.currentTimeMillis();
363                 if (maybeNotifyProgress(progress, lastProgress, now, lastUpdateTime, task, nm)) {
364                     lastUpdateTime = now;
365                 }
366                 lastProgress = progress;
367             }
368             return true;
369         }
370 
tryDeleteFile(@onNull Uri file)371         private void tryDeleteFile(@NonNull Uri file) {
372             try {
373                 // The file was not created by the DownloadService, however because the service
374                 // is only usable from this application, and the file should be created from this
375                 // same application, the content resolver should be the same.
376                 DocumentsContract.deleteDocument(getContentResolver(), file);
377             } catch (FileNotFoundException e) {
378                 // Nothing to delete
379             }
380         }
381 
getProgress(long contentLength, long totalRead)382         private Integer getProgress(long contentLength, long totalRead) {
383             if (contentLength == CONTENT_LENGTH_UNKNOWN || contentLength == 0) return null;
384             return (int) (totalRead * 100 / contentLength);
385         }
386 
387         /**
388          * Update the progress notification, if it was not updated recently.
389          * @return True if progress was updated.
390          */
maybeNotifyProgress(int progress, int lastProgress, long now, long lastProgressUpdateTimeMs, @NonNull DownloadTask task, @NonNull NotificationManager nm)391         private boolean maybeNotifyProgress(int progress, int lastProgress, long now,
392                 long lastProgressUpdateTimeMs, @NonNull DownloadTask task,
393                 @NonNull NotificationManager nm) {
394             if (lastProgress > 0 && progress < 100
395                     && lastProgressUpdateTimeMs > 0
396                     && now - lastProgressUpdateTimeMs < MAX_PROGRESS_UPDATE_RATE_MS) {
397                 // Rate-limit intermediate progress updates: NotificationManager will start ignoring
398                 // notifications from the current process if too many updates are posted too fast.
399                 // The shown progress will not "lag behind" much in most cases. An alternative
400                 // would be to delay the progress update to rate-limit, but this would bring
401                 // synchronization problems.
402                 return false;
403             }
404             final Notification note = makeProgressNotification(task, progress);
405             nm.notify(NOTE_DOWNLOAD_PROGRESS, note);
406             return true;
407         }
408     }
409 
410     @NonNull
makeProgressNotification(@onNull DownloadTask task, @Nullable Integer progress)411     private Notification makeProgressNotification(@NonNull DownloadTask task,
412             @Nullable Integer progress) {
413         return task.mCachedNotificationBuilder
414                 .setContentText(progress == null
415                         ? null
416                         : NumberFormat.getPercentInstance().format(progress.floatValue() / 100))
417                 .setProgress(100,
418                         progress == null ? 0 : progress,
419                         progress == null /* indeterminate */)
420                 .build();
421     }
422 
423     @NonNull
makeDoneNotification(int taskId, @NonNull String displayName, @NonNull Uri outFile)424     private Notification makeDoneNotification(int taskId, @NonNull String displayName,
425             @NonNull Uri outFile) {
426         final Intent intent = new Intent(Intent.ACTION_VIEW)
427                 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
428                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
429                 .setData(outFile)
430                 .setIdentifier(String.valueOf(taskId));
431 
432         final PendingIntent pendingIntent = PendingIntent.getActivity(
433                 this, 0 /* requestCode */, intent, 0 /* flags */);
434         return new Notification.Builder(this, CHANNEL_DOWNLOADS)
435                 .setContentTitle(getResources().getString(R.string.download_completed))
436                 .setContentText(displayName)
437                 .setSmallIcon(R.drawable.ic_cloud_download)
438                 .setContentIntent(pendingIntent)
439                 .setAutoCancel(true)
440                 .build();
441     }
442 
443     @NonNull
makeErrorNotification(@onNull String filename)444     private Notification makeErrorNotification(@NonNull String filename) {
445         final Resources res = getResources();
446         return new Notification.Builder(this, CHANNEL_DOWNLOADS)
447                 .setContentTitle(res.getString(R.string.error_downloading_paramfile, filename))
448                 .setSmallIcon(R.drawable.ic_cloud_download)
449                 .build();
450     }
451 }
452