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