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