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