AbstractFetchDownloadService.kt 13.8 KB
Newer Older
1
2
3
4
5
6
7
8
9
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.feature.downloads

import android.annotation.TargetApi
import android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE
import android.app.DownloadManager.EXTRA_DOWNLOAD_ID
10
11
import android.app.Service
import android.content.BroadcastReceiver
12
import android.content.ContentValues
13
14
import android.content.Context
import android.content.Intent
15
16
import android.content.Intent.ACTION_VIEW
import android.content.IntentFilter
17
18
19
20
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import android.os.Environment
import android.os.IBinder
21
22
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
23
24
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
25
import androidx.core.content.FileProvider
26
27
import androidx.core.net.toUri
import androidx.localbroadcastmanager.content.LocalBroadcastManager
28
import kotlinx.coroutines.CoroutineScope
29
import kotlinx.coroutines.Dispatchers.IO
30
31
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
32
import mozilla.components.browser.state.state.content.DownloadState
33
import mozilla.components.concept.fetch.Client
34
35
36
import mozilla.components.concept.fetch.Headers.Names.CONTENT_RANGE
import mozilla.components.concept.fetch.Headers.Names.RANGE
import mozilla.components.concept.fetch.MutableHeaders
37
import mozilla.components.concept.fetch.Request
38
import mozilla.components.feature.downloads.ext.addCompletedDownload
39
import mozilla.components.feature.downloads.ext.getDownloadExtra
40
import mozilla.components.feature.downloads.ext.withResponse
41
42
import java.io.File
import java.io.FileOutputStream
43
import java.io.IOException
44
import java.io.InputStream
45
import java.io.OutputStream
46
import kotlin.random.Random
47
48
49
50
51
52
53

/**
 * Service that performs downloads through a fetch [Client] rather than through the native
 * Android download manager.
 *
 * To use this service, you must create a subclass in your application and it to the manifest.
 */
54
55
@Suppress("TooManyFunctions", "LargeClass")
abstract class AbstractFetchDownloadService : Service() {
56
57

    protected abstract val httpClient: Client
58
59
60
61
    @VisibleForTesting
    internal val broadcastManager by lazy { LocalBroadcastManager.getInstance(this) }
    @VisibleForTesting
    internal val context: Context get() = this
62

63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
    internal var downloadJobs = mutableMapOf<Long, DownloadJobState>()

    internal data class DownloadJobState(
        var job: Job? = null,
        var state: DownloadState,
        var currentBytesCopied: Long = 0,
        var status: DownloadJobStatus,
        var foregroundServiceId: Int = 0
    )

    internal enum class DownloadJobStatus {
        ACTIVE,
        PAUSED,
        CANCELLED,
        FAILED
    }

    internal val broadcastReceiver by lazy {
        object : BroadcastReceiver() {
            override fun onReceive(context: Context, intent: Intent?) {
                val downloadId =
                        intent?.extras?.getLong(DownloadNotification.EXTRA_DOWNLOAD_ID) ?: return
                val currentDownloadJobState = downloadJobs[downloadId] ?: return

                when (intent.action) {
                    ACTION_PAUSE -> {
                        currentDownloadJobState.status = DownloadJobStatus.PAUSED
                        currentDownloadJobState.job?.cancel()
                    }

                    ACTION_RESUME -> {
                        currentDownloadJobState.status = DownloadJobStatus.ACTIVE

                        currentDownloadJobState.job = CoroutineScope(IO).launch {
                            startDownloadJob(currentDownloadJobState.state)
                        }
                    }

                    ACTION_CANCEL -> {
102
103
104
                        NotificationManagerCompat.from(context).cancel(
                            currentDownloadJobState.foregroundServiceId
                        )
105
                        currentDownloadJobState.status = DownloadJobStatus.CANCELLED
106

107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
                        currentDownloadJobState.job?.cancel()
                    }

                    ACTION_TRY_AGAIN -> {
                        NotificationManagerCompat.from(context).cancel(
                            currentDownloadJobState.foregroundServiceId
                        )
                        currentDownloadJobState.status = DownloadJobStatus.ACTIVE

                        currentDownloadJobState.job = CoroutineScope(IO).launch {
                            startDownloadJob(currentDownloadJobState.state)
                        }
                    }

                    ACTION_OPEN -> {
                        // Create a new file with the location of the saved file to extract the correct path
                        // `file` has the wrong path, so we must construct it based on the `fileName` and `dir.path`s
                        val fileLocation = File(currentDownloadJobState.state.filePath)
                        val filePath = FileProvider.getUriForFile(
                                context,
                                context.packageName + FILE_PROVIDER_EXTENSION,
                                fileLocation
                        )

                        val newIntent = Intent(ACTION_VIEW).apply {
                            setDataAndType(filePath, currentDownloadJobState.state.contentType ?: "*/*")
                            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
                        }

                        startActivity(newIntent)
                    }
                }
            }
        }
141
142
143
144
    }

    override fun onBind(intent: Intent?): IBinder? = null

145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val download = intent?.getDownloadExtra() ?: return START_REDELIVER_INTENT
        registerForUpdates()

        val foregroundServiceId = Random.nextInt()

        // Create a new job and add it, with its downloadState to the map
        downloadJobs[download.id] = DownloadJobState(
            state = download,
            foregroundServiceId = foregroundServiceId,
            status = DownloadJobStatus.ACTIVE
        )

        downloadJobs[download.id]?.job = CoroutineScope(IO).launch {
            startDownloadJob(download)
        }

        return super.onStartCommand(intent, flags, startId)
    }
164

165
166
167
168
169
170
171
172
    override fun onDestroy() {
        super.onDestroy()
        downloadJobs.values.forEach {
            it.job?.cancel()
        }
    }

    internal fun startDownloadJob(download: DownloadState) {
173
174
        val notification = try {
            performDownload(download)
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
            when (downloadJobs[download.id]?.status) {
                DownloadJobStatus.CANCELLED -> { return }

                DownloadJobStatus.PAUSED -> {
                    DownloadNotification.createPausedDownloadNotification(context, download)
                }

                DownloadJobStatus.ACTIVE -> {
                    DownloadNotification.createDownloadCompletedNotification(context, download)
                }

                DownloadJobStatus.FAILED -> {
                    DownloadNotification.createDownloadFailedNotification(context, download)
                }

                null -> { return }
            }
192
        } catch (e: IOException) {
193
            DownloadNotification.createDownloadFailedNotification(context, download)
194
        }
195

196
        NotificationManagerCompat.from(context).notify(
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
                downloadJobs[download.id]?.foregroundServiceId ?: 0,
                notification
        )

        sendDownloadCompleteBroadcast(download.id)
    }

    private fun registerForUpdates() {
        val filter = IntentFilter().apply {
            addAction(ACTION_PAUSE)
            addAction(ACTION_RESUME)
            addAction(ACTION_CANCEL)
            addAction(ACTION_TRY_AGAIN)
            addAction(ACTION_OPEN)
        }

        context.registerReceiver(broadcastReceiver, filter)
    }

    private fun displayOngoingDownloadNotification(download: DownloadState) {
        val ongoingDownloadNotification = DownloadNotification.createOngoingDownloadNotification(
218
            context,
219
            download
220
        )
221

222
        NotificationManagerCompat.from(context).notify(
223
224
225
            downloadJobs[download.id]?.foregroundServiceId ?: 0,
            ongoingDownloadNotification
        )
226
227
    }

228
229
230
231
    @Suppress("ComplexCondition")
    internal fun performDownload(download: DownloadState) {
        val isResumingDownload = downloadJobs[download.id]?.currentBytesCopied ?: 0L > 0L
        val headers = MutableHeaders()
232

233
234
235
        if (isResumingDownload) {
            headers.append(RANGE, "bytes=${downloadJobs[download.id]?.currentBytesCopied}-")
        }
236

237
        val request = Request(download.url, headers = headers)
238
239
        val response = httpClient.fetch(request)

240
241
242
243
244
245
246
247
248
249
        // If we are resuming a download and the response does not contain a CONTENT_RANGE
        // we cannot be sure that the request will properly be handled
        if (response.status != PARTIAL_CONTENT_STATUS && response.status != OK_STATUS ||
            (isResumingDownload && !response.headers.contains(CONTENT_RANGE))) {
            // We experienced a problem trying to fetch the file, send a failure notification
            downloadJobs[download.id]?.currentBytesCopied = 0
            downloadJobs[download.id]?.status = DownloadJobStatus.FAILED
            return
        }

250
        response.body.useStream { inStream ->
251
252
253
254
255
256
257
            val newDownloadState = download.withResponse(response.headers, inStream)
            downloadJobs[download.id]?.state = newDownloadState

            displayOngoingDownloadNotification(newDownloadState)

            useFileStream(newDownloadState, isResumingDownload) { outStream ->
                copyInChunks(downloadJobs[download.id]!!, inStream, outStream)
258
259
260
261
            }
        }
    }

262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
    private fun copyInChunks(downloadJobState: DownloadJobState, inStream: InputStream, outStream: OutputStream) {
        // To ensure that we copy all files (even ones that don't have fileSize, we must NOT check < fileSize
        while (downloadJobState.status == DownloadJobStatus.ACTIVE) {
            val data = ByteArray(CHUNK_SIZE)
            val bytesRead = inStream.read(data)

            // If bytesRead is -1, there's no data left to read from the stream
            if (bytesRead == -1) { break }

            downloadJobState.currentBytesCopied += bytesRead

            outStream.write(data, 0, bytesRead)
        }
    }

277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
    /**
     * Informs [mozilla.components.feature.downloads.manager.FetchDownloadManager] that a download
     * has been completed.
     */
    private fun sendDownloadCompleteBroadcast(downloadID: Long) {
        val intent = Intent(ACTION_DOWNLOAD_COMPLETE)
        intent.putExtra(EXTRA_DOWNLOAD_ID, downloadID)
        broadcastManager.sendBroadcast(intent)
    }

    /**
     * Creates an output stream on the local filesystem, then informs the system that a download
     * is complete after [block] is run.
     *
     * Encapsulates different behaviour depending on the SDK version.
     */
    internal fun useFileStream(
294
        download: DownloadState,
295
        append: Boolean,
296
297
        block: (OutputStream) -> Unit
    ) {
298
        if (SDK_INT >= Build.VERSION_CODES.Q) {
299
            useFileStreamScopedStorage(download, block)
300
        } else {
301
            useFileStreamLegacy(download, append, block)
302
303
304
305
        }
    }

    @TargetApi(Build.VERSION_CODES.Q)
306
    private fun useFileStreamScopedStorage(download: DownloadState, block: (OutputStream) -> Unit) {
307
        val values = ContentValues().apply {
308
309
310
            put(MediaStore.Downloads.DISPLAY_NAME, download.fileName)
            put(MediaStore.Downloads.MIME_TYPE, download.contentType ?: "*/*")
            put(MediaStore.Downloads.SIZE, download.contentLength)
311
312
            put(MediaStore.Downloads.IS_PENDING, 1)
        }
313

314
315
316
        val resolver = applicationContext.contentResolver
        val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
        val item = resolver.insert(collection, values)
317

318
319
        val pfd = resolver.openFileDescriptor(item!!, "w")
        ParcelFileDescriptor.AutoCloseOutputStream(pfd).use(block)
320

321
322
323
324
325
326
        values.clear()
        values.put(MediaStore.Downloads.IS_PENDING, 0)
        resolver.update(item, values, null, null)
    }

    @TargetApi(Build.VERSION_CODES.P)
327
    @Suppress("Deprecation")
328
    private fun useFileStreamLegacy(download: DownloadState, append: Boolean, block: (OutputStream) -> Unit) {
329
        val dir = Environment.getExternalStoragePublicDirectory(download.destinationDirectory)
330
        val file = File(dir, download.fileName!!)
331
332

        FileOutputStream(file, append).use(block)
333
334

        addCompletedDownload(
335
336
            title = download.fileName!!,
            description = download.fileName!!,
337
            isMediaScannerScannable = true,
338
            mimeType = download.contentType ?: "*/*",
339
            path = file.absolutePath,
340
            length = download.contentLength ?: file.length(),
341
342
            // Only show notifications if our channel is blocked
            showNotification = !DownloadNotification.isChannelEnabled(context),
343
344
345
346
347
348
            uri = download.url.toUri(),
            referer = download.referrerUrl?.toUri()
        )
    }

    companion object {
349
350
351
352
353
354
355
356
357
358
        private const val FILE_PROVIDER_EXTENSION = ".fileprovider"
        private const val CHUNK_SIZE = 4 * 1024
        private const val PARTIAL_CONTENT_STATUS = 206
        private const val OK_STATUS = 200

        const val ACTION_OPEN = "mozilla.components.feature.downloads.OPEN"
        const val ACTION_PAUSE = "mozilla.components.feature.downloads.PAUSE"
        const val ACTION_RESUME = "mozilla.components.feature.downloads.RESUME"
        const val ACTION_CANCEL = "mozilla.components.feature.downloads.CANCEL"
        const val ACTION_TRY_AGAIN = "mozilla.components.feature.downloads.TRY_AGAIN"
359
360
    }
}