diff --git a/docs/_coverpage.md b/docs/_coverpage.md index 71ffc63..b47857a 100644 --- a/docs/_coverpage.md +++ b/docs/_coverpage.md @@ -1,6 +1,6 @@ -# Android Http Download Manager 2.0.0 +# Android Http Download Manager 2.1.0 [GitHub](https://github.com/coolerfall/Android-HttpDownloadManager) [Get Started](#android-http-download-manager) diff --git a/docs/configuration.md b/docs/configuration.md index 9c1ccdb..137edc6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,29 +2,48 @@ You can costomize `DownloadManager` with the fllowing configurations. - -### context - -* Type: `android.content.Context` -* Optional: `false` - -Application context to get download root directory, this cannot be null. - ### downloader * Type: `com.coolerfall.download.Downloader` * Optional: `true` -You can implement a `Downloader` with any http library. The download manager will detect http library and choose available `Downloader` to use if not set. The download manager provides `OkHttpDownloader` and `URLDownloader` currently. +You can implement a `Downloader` with any http library. The download manager will detect http +library and choose available `Downloader` to use if not set. The download manager +provides `OkHttpDownloader` and `URLDownloader` currently. -!> If you're using `OkHttpDownloader` with custom `OkHttpClient` as `Downloader` in `DownloadManager`, then you should not add `HttpLoggingInterceptor` in your custom `OkHttpClient`. It may be crashed(OOM) as `HttpLoggingInterceptor ` use `okio` to reqeust the whole body in memory. +!> If you're using `OkHttpDownloader` with custom `OkHttpClient` as `Downloader` +in `DownloadManager`, then you should not add `HttpLoggingInterceptor` in your custom `OkHttpClient` +. It may be crashed(OOM) as `HttpLoggingInterceptor ` use `okio` to reqeust the whole body in +memory. ### threadPoolSize * Type: `int` * Default: `3` -The pool size of the download dispatcher thread. The default size will be set if less than 0 or more than 10. +The pool size of the download dispatcher thread. The default size will be set if less than 0 or more +than 10. + +### retryTime + +* Type: `int` +* Default: `3` + +How many times you want to retry if download failed for some network problem and so on. + +### retryInterval + +* Type `long, java.util.concurrent.TimeUnit` +* Default: `30 seconds` + +Interval of each retry. + +### progressInterval + +* Type `long, java.util.concurrent.TimeUnit` +* Default: `100 milliseconds` + +Interval of progress refreshing. ### logger @@ -33,8 +52,7 @@ The pool size of the download dispatcher thread. The default size will be set if Log necessary information when downloading. If you don't care this, just ignore. -> If you want to copy files to external public download directory, `DownloadManager` provides `copyToPublicDownloadDir(String filepath)`. - +> If you want to custom `DownloadManager`, then use `DownloadManager.with(builder)`. ## Download Request @@ -59,19 +77,16 @@ This is an alternative of [url](#url), you can choose one to set. A unique id of `DownloadRequest`, it will be set automatically if not set. -### relativeDirectory +### pack -* Type: `String` -* Optional: `true` +* Type: `Pack` +* Optional: `false` -After android Q, we can just save files in external private directory and public download directory, so the download manager will use `Context#getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)` as download root directory. The download manager will extract filename from header or url. +`Pack` is the target where the file will be put. Some builtin pack: -### relativeFilepath - -* Type: `String` -* Optional: `true` - -Set filepath mannully. The download manager will use this filepath instead of auto detecting. +* ExtPublicPack: put file in external public directory such public `Download` directory, + see `Environment.getExternalStoragePublicDirectory` +* ExtFilePack: put file in external files directory, see `Context.getExternalFilesDir` ### priority @@ -80,27 +95,6 @@ Set filepath mannully. The download manager will use this filepath instead of au Higher priority will download first. -### retryTime - -* Type: `int` -* Default: `3` - -How many times you want to retry if download failed for some network problem and so on. - -### retryInterval - -* Type `long, java.util.concurrent.TimeUnit` -* Default: `30 seconds` - -Interval of each retry. - -### progressInterval - -* Type `long, java.util.concurrent.TimeUnit` -* Default: `100 milliseconds` - -Interval of progress refreshing. - ### downloadCallback * Type: `com.coolerfall.download.DownloadCallback` diff --git a/docs/quickstart.md b/docs/quickstart.md index 7a161a9..bb7ee6b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -9,15 +9,14 @@ implementation 'com.coolerfall:android-http-download-manager:2.0.0' * Now download a file with this: ```java -DownloadManager manager = new DownloadManager.Builder() - .context(this) - .build(); +import com.coolerfall.download.DownloadManager; +import com.coolerfall.download.DownloadRequest; DownloadRequest request = new DownloadRequest.Builder() .url("http://something.to.download") .build(); -int downloadId = manager.add(request); +int downloadId = DownloadManager.get().enqueue(request); ``` For more details, see [configuration](/configuration) \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 47dc418..b091b77 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ GROUP=com.coolerfall POM_ARTIFACT_ID=android-http-download-manager -VERSION_NAME=2.0.1-SNAPSHOT +VERSION_NAME=2.1.0-SNAPSHOT POM_NAME=Android Http Download Manager POM_DESCRIPTION=An useful and effective http/https download manager for Android, support breakpoint downloading diff --git a/library/src/main/java/com/coolerfall/download/BreakpointPack.kt b/library/src/main/java/com/coolerfall/download/BreakpointPack.kt new file mode 100644 index 0000000..3bbd42c --- /dev/null +++ b/library/src/main/java/com/coolerfall/download/BreakpointPack.kt @@ -0,0 +1,25 @@ +package com.coolerfall.download + +/** + * This [Pack] represents a pack which supports breakpoint transfer. + * + * @author Vincent Cheung (coolingfall@gmail.com) + */ +abstract class BreakpointPack(filename: String?) : Pack(filename) { + + /** + * Get pending length if pending file exists. + * + * @return pending length + */ + internal abstract fun pendingLength(): Long + + /** + * Get a unique filename for pending file. + * + * @return unique filename + */ + internal fun uniqueFilename(): String { + return Helper.md5(requireFilename()) + "-" + requireFilename() + } +} \ No newline at end of file diff --git a/library/src/main/java/com/coolerfall/download/DirectFilePack.kt b/library/src/main/java/com/coolerfall/download/DirectFilePack.kt new file mode 100644 index 0000000..6ea668c --- /dev/null +++ b/library/src/main/java/com/coolerfall/download/DirectFilePack.kt @@ -0,0 +1,44 @@ +package com.coolerfall.download + +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream + +/** + * A [Pack] with direct [File] api operation. + * + * @author Vincent Cheung (coolingfall@gmail.com) + */ +open class DirectFilePack( + private val dir: String, filename: String? = null +) : BreakpointPack(filename) { + + init { + val directory = File(dir) + if (!directory.exists()) { + directory.mkdirs() + } + } + + override fun pendingLength(): Long { + return File(pendingPath()).length() + } + + override fun open(): OutputStream { + return FileOutputStream(pendingPath(), true) + } + + override fun finish() { + val filepath = Helper.resolvePath(dir, requireFilename()) + File(pendingPath()).renameTo(File(filepath)) + } + + /** + * Get a temporary filename when transferring. + * + * @return pending filename + */ + private fun pendingPath(): String { + return Helper.resolvePath(dir, ".pending-" + uniqueFilename()) + } +} \ No newline at end of file diff --git a/library/src/main/java/com/coolerfall/download/DownloadCallback.kt b/library/src/main/java/com/coolerfall/download/DownloadCallback.kt index 7a811c7..9fa4e6c 100644 --- a/library/src/main/java/com/coolerfall/download/DownloadCallback.kt +++ b/library/src/main/java/com/coolerfall/download/DownloadCallback.kt @@ -29,7 +29,8 @@ interface DownloadCallback { * @param downloadId download id in download request queue */ @MainThread - fun onRetry(downloadId: Int) {} + fun onRetry(downloadId: Int) { + } /** * Invoked when downloading is in progress. @@ -48,10 +49,25 @@ interface DownloadCallback { * @param downloadId download id in download request queue * @param filepath the filepath of downloaded file */ + @Deprecated( + level = DeprecationLevel.ERROR, + message = "Use pack instead", + replaceWith = ReplaceWith("onSuccess(downloadId, pack)") + ) @MainThread fun onSuccess(downloadId: Int, filepath: String) { } + /** + * Invoked when downloading successfully. + * + * @param downloadId download id in download request queue + * @param pack the target [Pack] + */ + @MainThread + fun onSuccess(downloadId: Int, pack: Pack) { + } + /** * Invoked when downloading failed. * diff --git a/library/src/main/java/com/coolerfall/download/DownloadDelivery.kt b/library/src/main/java/com/coolerfall/download/DownloadDelivery.kt index 1c6674d..4a7919d 100644 --- a/library/src/main/java/com/coolerfall/download/DownloadDelivery.kt +++ b/library/src/main/java/com/coolerfall/download/DownloadDelivery.kt @@ -2,7 +2,6 @@ package com.coolerfall.download import android.os.Handler import java.util.concurrent.Executor -import java.lang.Runnable /** * This class is used to delivery callback to call back in main thread. @@ -63,7 +62,7 @@ internal class DownloadDelivery(handler: Handler) { */ fun postSuccess(request: DownloadRequest) { downloadPoster.execute { - request.downloadCallback.onSuccess(request.downloadId, request.destinationFilepath()) + request.downloadCallback.onSuccess(request.downloadId, request.pack) } } diff --git a/library/src/main/java/com/coolerfall/download/DownloadDispatcher.kt b/library/src/main/java/com/coolerfall/download/DownloadDispatcher.kt index a9ebc32..979591d 100644 --- a/library/src/main/java/com/coolerfall/download/DownloadDispatcher.kt +++ b/library/src/main/java/com/coolerfall/download/DownloadDispatcher.kt @@ -4,10 +4,10 @@ import android.os.Process import com.coolerfall.download.DownloadState.FAILURE import com.coolerfall.download.DownloadState.RUNNING import com.coolerfall.download.DownloadState.SUCCESSFUL -import java.io.File +import java.io.Closeable import java.io.IOException import java.io.InputStream -import java.io.RandomAccessFile +import java.io.OutputStream import java.util.concurrent.BlockingQueue /** @@ -107,17 +107,11 @@ internal class DownloadDispatcher( } /* update download success */ - private fun updateSuccess( - request: DownloadRequest - ) { + private fun updateSuccess(request: DownloadRequest) { updateState(request, SUCCESSFUL) /* notify the request download finish */ request.finish() - val file = File(request.tempFilepath()) - if (file.exists()) { - file.renameTo(File(request.destinationFilepath())) - } /* deliver success message */ delivery.postSuccess(request) @@ -167,36 +161,38 @@ internal class DownloadDispatcher( return } val downloader = request.downloader - var raf: RandomAccessFile? = null - var `is`: InputStream? = null + var inputStream: InputStream? = null + var outputStream: OutputStream? = null + val pack = request.pack try { - request.updateDestinationFilepath(downloader.detectFilename(request.uri())) - val file = File(request.tempFilepath()) - val fileExists = file.exists() - raf = RandomAccessFile(file, "rw") - val breakpoint = file.length() + pack.putFilenameIfAbsent { + downloader.detectFilename(request.uri) + } + + val breakpoint = if (pack is BreakpointPack) pack.pendingLength() else 0 var bytesWritten: Long = 0 - if (fileExists) { - /* set the range to continue the downloading */ - raf.seek(breakpoint) - bytesWritten = breakpoint + if (breakpoint > 0) { logger.log("Detect existed file with $breakpoint bytes, start breakpoint downloading") + bytesWritten = breakpoint } - val statusCode = downloader.start(request.uri(), breakpoint) + + val statusCode = downloader.start(request.uri, breakpoint) if (statusCode != Helper.HTTP_OK && statusCode != Helper.HTTP_PARTIAL) { logger.log("Incorrect http code got: $statusCode") throw DownloadException(statusCode, "download fail") } - `is` = downloader.byteStream() + inputStream = downloader.byteStream() var contentLength = downloader.contentLength() - if (contentLength <= 0 && `is` == null) { + if (contentLength <= 0 && inputStream == null) { throw DownloadException(statusCode, "content length error") } + outputStream = pack.open() + val noContentLength = contentLength <= 0 contentLength += bytesWritten updateStart(request, contentLength) logger.log("Start to download, content length: $contentLength bytes") - if (`is` != null) { + if (inputStream != null) { val buffer = ByteArray(BUFFER_SIZE) var length: Int while (true) { @@ -207,9 +203,7 @@ internal class DownloadDispatcher( } /* read data into buffer from input stream */ - length = readFromInputStream(buffer, `is`) - val fileSize = raf.length() - val totalBytes = if (noContentLength) fileSize else contentLength + length = readFromInputStream(buffer, inputStream) if (length == END_OF_STREAM) { updateSuccess(request) return @@ -218,9 +212,10 @@ internal class DownloadDispatcher( } bytesWritten += length.toLong() /* write buffer into local file */ - raf.write(buffer, 0, length) + outputStream.write(buffer, 0, length) /* deliver progress callback */ + val totalBytes = if (noContentLength) bytesWritten else contentLength updateProgress(request, bytesWritten, totalBytes) } } else { @@ -235,18 +230,15 @@ internal class DownloadDispatcher( } } finally { downloader.close() - silentCloseFile(raf) - silentCloseInputStream(`is`) + silentCloseStream(outputStream) + silentCloseStream(inputStream) } } /* read data from input stream */ - private fun readFromInputStream( - buffer: ByteArray?, - `is`: InputStream - ): Int { + private fun readFromInputStream(buffer: ByteArray?, inputStream: InputStream): Int { return try { - `is`.read(buffer) + inputStream.read(buffer) } catch (e: IOException) { logger.log("Transfer data with exception: " + e.message) Int.MIN_VALUE @@ -265,18 +257,8 @@ internal class DownloadDispatcher( interrupt() } - /* a utility function to close a random access file without raising an exception */ - private fun silentCloseFile(raf: RandomAccessFile?) { - if (raf != null) { - try { - raf.close() - } catch (ignore: IOException) { - } - } - } - /* a utility function to close an input stream without raising an exception */ - private fun silentCloseInputStream(stream: InputStream?) { + private fun silentCloseStream(stream: Closeable?) { try { stream?.close() } catch (ignore: IOException) { diff --git a/library/src/main/java/com/coolerfall/download/DownloadManager.kt b/library/src/main/java/com/coolerfall/download/DownloadManager.kt index 3531b6d..70a5c44 100644 --- a/library/src/main/java/com/coolerfall/download/DownloadManager.kt +++ b/library/src/main/java/com/coolerfall/download/DownloadManager.kt @@ -1,24 +1,8 @@ package com.coolerfall.download -import android.annotation.SuppressLint -import android.content.ContentValues -import android.content.Context import android.net.Uri -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES -import android.os.Environment -import android.provider.MediaStore.Downloads -import android.provider.MediaStore.Files.FileColumns -import android.webkit.MimeTypeMap import com.coolerfall.download.DownloadState.INVALID -import com.coolerfall.download.Helper.copy import com.coolerfall.download.Helper.createDefaultDownloader -import com.coolerfall.download.Helper.resolvePath -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException -import java.io.OutputStream import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -28,31 +12,23 @@ import java.util.concurrent.atomic.AtomicInteger * @author Vincent Cheung (coolingfall@gmail.com) */ class DownloadManager internal constructor(builder: Builder) { - private val context: Context = requireNotNull(DownloadProvider.appContext) { "context == null" } private val downloader: Downloader = requireNotNull(builder.downloader) { "downloader == null" } private val threadPoolSize: Int = builder.threadPoolSize private val logger: Logger = builder.logger private val downloadRequestQueue = DownloadRequestQueue(threadPoolSize, logger) - private val rootDownloadDir: String private val progressIntervalMs: Long = builder.progressIntervalMs private val retryTime: Int = builder.retryTime private val retryIntervalMs: Long = builder.retryIntervalMs - val taskSize: Int get() = downloadRequestQueue.downloadingSize init { downloadRequestQueue.start() - val downloadDir = - requireNotNull(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) { - "shared storage is not currently available" - } - rootDownloadDir = downloadDir.absolutePath ?: "" } companion object { - @SuppressLint("StaticFieldLeak") private var downloadManager: DownloadManager? = null + private var builder: Builder? = null @JvmStatic @Synchronized @@ -60,11 +36,16 @@ class DownloadManager internal constructor(builder: Builder) { /* check again in case download manager was just set */ downloadManager?.let { return it } - val newDownloadManager = Builder().build() + val newDownloadManager = (builder ?: Builder()).build() downloadManager = newDownloadManager return newDownloadManager } + + @JvmStatic + fun withBuilder(builder: Builder) { + this.builder = builder + } } @Deprecated( @@ -84,13 +65,12 @@ class DownloadManager internal constructor(builder: Builder) { * if the request is in downloading, then -1 will be returned */ fun enqueue(request: DownloadRequest): Int { - if (isDownloading(request.uri().toString())) { + if (isDownloading(request.uri.toString())) { return -1 } request.progressInterval = progressIntervalMs request.retryTime = AtomicInteger(retryTime) request.retryInterval = retryIntervalMs - request.rootDownloadDir(rootDownloadDir) request.downloader(downloader.copy()) /* add download request into download request queue */ @@ -187,55 +167,15 @@ class DownloadManager internal constructor(builder: Builder) { * @param filepath filepath of downloaded file * @return true if copy successfully, otherwise return false */ + @Deprecated( + level = DeprecationLevel.ERROR, + message = "Use pack instead", + replaceWith = ReplaceWith("request.target(pack)") + ) fun copyToPublicDownloadDir(filepath: String): Boolean { - if (!filepath.startsWith(rootDownloadDir)) { - logger.log("Only files of current app can be exported") - return false - } - - try { - openOutputStream(filepath)?.use { it -> - val fis = FileInputStream(filepath) - copy(fis, it) - return true - } - } catch (e: Exception) { - logger.log("Failed to copy file to public download directory: " + e.message) - } - return false } - @Throws(IOException::class) private fun openOutputStream( - filepath: String - ): OutputStream? { - val filename = filepath.substring(filepath.lastIndexOf(File.separator) + 1) - - return if (VERSION.SDK_INT >= VERSION_CODES.Q) { - val contentValues = ContentValues() - contentValues.put( - FileColumns.RELATIVE_PATH, - Environment.DIRECTORY_DOWNLOADS - ) - contentValues.put(FileColumns.DISPLAY_NAME, filename) - val index = filename.lastIndexOf(".") - if (index > 0) { - val mimeType = MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(filename.substring(index + 1)) - contentValues.put(FileColumns.MIME_TYPE, mimeType) - } - val contentResolver = context.contentResolver - val uri = contentResolver.insert(Downloads.EXTERNAL_CONTENT_URI, contentValues) - ?: throw IOException("Cannot get shared download directory") - contentResolver.openOutputStream(uri) - } else { - val dir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath - val outputFilepath = resolvePath(dir, filepath.substring(rootDownloadDir.length + 1)) - FileOutputStream(outputFilepath) - } - } - fun newBuilder(): Builder { return Builder(this) } diff --git a/library/src/main/java/com/coolerfall/download/DownloadProvider.kt b/library/src/main/java/com/coolerfall/download/DownloadProvider.kt index 409bf5f..4eb603b 100644 --- a/library/src/main/java/com/coolerfall/download/DownloadProvider.kt +++ b/library/src/main/java/com/coolerfall/download/DownloadProvider.kt @@ -18,6 +18,10 @@ class DownloadProvider : ContentProvider() { companion object { @SuppressLint("StaticFieldLeak") internal var appContext: Context? = null + + internal fun requireContext(): Context { + return requireNotNull(appContext) { "context is not ready" } + } } override fun onCreate(): Boolean { diff --git a/library/src/main/java/com/coolerfall/download/DownloadRequest.kt b/library/src/main/java/com/coolerfall/download/DownloadRequest.kt index 9e57685..e0ef5d3 100644 --- a/library/src/main/java/com/coolerfall/download/DownloadRequest.kt +++ b/library/src/main/java/com/coolerfall/download/DownloadRequest.kt @@ -2,9 +2,7 @@ package com.coolerfall.download import android.net.Uri import com.coolerfall.download.DownloadState.PENDING -import com.coolerfall.download.Helper.resolvePath import com.coolerfall.download.Priority.NORMAL -import java.io.File import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.properties.Delegates @@ -15,37 +13,23 @@ import kotlin.properties.Delegates * @author Vincent Cheung (coolingfall@gmail.com) */ class DownloadRequest private constructor(builder: Builder) : Comparable { - private val uri: Uri - private val relativeDirectory: String? - private val relativeFilepath: String? - private var destinationFilepath: String? = null - private lateinit var destinationDirectory: String + internal val uri: Uri = requireNotNull(builder.uri) { "uri == null" } + internal val pack: Pack = requireNotNull(builder.pack) { "pack == null" } private var downloadRequestQueue: DownloadRequestQueue? = null - private val timestamp: Long - private val priority: Priority + private val timestamp: Long = System.currentTimeMillis() + private val priority: Priority = requireNotNull(builder.priority) { "priority == null" } lateinit var downloader: Downloader private set - internal val downloadCallback: DownloadCallback - internal var downloadId: Int - private set - internal var downloadState: DownloadState + internal val downloadCallback: DownloadCallback = + requireNotNull(builder.downloadCallback) { "downloadCallback == null" } + internal var downloadId: Int = builder.downloadId + internal var downloadState: DownloadState = PENDING internal var progressInterval by Delegates.notNull() internal lateinit var retryTime: AtomicInteger internal var retryInterval by Delegates.notNull() internal var isCanceled = false private set - init { - downloadId = builder.downloadId - uri = requireNotNull(builder.uri) { "uri == null" } - priority = requireNotNull(builder.priority) { "priority == null" } - relativeDirectory = builder.relativeDirectory - relativeFilepath = builder.relativeFilepath - downloadCallback = requireNotNull(builder.downloadCallback) { "downloadCallback == null" } - downloadState = PENDING - timestamp = System.currentTimeMillis() - } - override fun compareTo(other: DownloadRequest): Int { val left = priority val right = other.priority @@ -81,67 +65,6 @@ class DownloadRequest private constructor(builder: Builder) : Comparable= VERSION_CODES.Q) MediaPack(type, filename) + else DirectFilePack( + Helper.resolvePath( + Environment.getExternalStoragePublicDirectory(type).absolutePath, + subDir ?: "" + ), + filename + ) + + override fun putFilenameIfAbsent(filename: () -> String) { + pack.putFilenameIfAbsent(filename) + } + + override fun pendingLength(): Long { + return pack.pendingLength() + } + + override fun open(): OutputStream { + return pack.open() + } + + override fun finish() { + pack.finish() + } +} \ No newline at end of file diff --git a/library/src/main/java/com/coolerfall/download/Helper.kt b/library/src/main/java/com/coolerfall/download/Helper.kt index 359040a..7c06763 100644 --- a/library/src/main/java/com/coolerfall/download/Helper.kt +++ b/library/src/main/java/com/coolerfall/download/Helper.kt @@ -37,19 +37,15 @@ object Helper { .replace("-".toRegex(), "") /* calculate md5 for string */ - private fun md5(origin: String): String { - return try { - val md = MessageDigest.getInstance("MD5") - md.update(origin.toByteArray(StandardCharsets.UTF_8)) - val bi = BigInteger(1, md.digest()) - val hash = StringBuilder(bi.toString(16)) - while (hash.length < 32) { - hash.insert(0, "0") - } - hash.toString() - } catch (e: Exception) { - uuid + internal fun md5(origin: String): String { + val md = MessageDigest.getInstance("MD5") + md.update(origin.toByteArray(StandardCharsets.UTF_8)) + val bi = BigInteger(1, md.digest()) + val hash = StringBuilder(bi.toString(16)) + while (hash.length < 32) { + hash.insert(0, "0") } + return hash.toString() } /** @@ -58,8 +54,8 @@ object Helper { * @param url url * @return filename or md5 if no available filename */ - fun getFilenameFromUrl(url: String): String { - var filename = md5(url) + ".down" + private fun getFilenameFromUrl(url: String): String { + var filename = md5(url) + ".file" val index = url.lastIndexOf("/") if (index > 0) { var tmpFilename = url.substring(index + 1) diff --git a/library/src/main/java/com/coolerfall/download/MediaPack.kt b/library/src/main/java/com/coolerfall/download/MediaPack.kt new file mode 100644 index 0000000..2726788 --- /dev/null +++ b/library/src/main/java/com/coolerfall/download/MediaPack.kt @@ -0,0 +1,117 @@ +package com.coolerfall.download + +import android.content.ContentResolver.QUERY_ARG_SQL_SELECTION +import android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS +import android.content.ContentValues +import android.net.Uri +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import android.provider.MediaStore.Audio +import android.provider.MediaStore.Downloads +import android.provider.MediaStore.Files.FileColumns +import android.provider.MediaStore.Images +import android.provider.MediaStore.Video +import android.webkit.MimeTypeMap +import androidx.annotation.RequiresApi +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import java.io.OutputStream + +/** + * A [Pack] implementation with [MediaStore] api. This pack is only available after Q. + * + * @author Vincent Cheung (coolingfall@gmail.com) + */ +@RequiresApi(VERSION_CODES.Q) +class MediaPack( + private val type: String = Environment.DIRECTORY_DOWNLOADS, + private val subDir: String? = null, + filename: String? = null +) : BreakpointPack(filename) { + + private val contentUri = requireNotNull(URI_MAP[type]) { "Type [$type] not supported" } + private val contentResolver = DownloadProvider.requireContext().contentResolver + + companion object { + private val URI_MAP = mutableMapOf( + Pair(Environment.DIRECTORY_DOWNLOADS, Downloads.EXTERNAL_CONTENT_URI), + Pair(Environment.DIRECTORY_PICTURES, Images.Media.EXTERNAL_CONTENT_URI), + Pair(Environment.DIRECTORY_DCIM, Images.Media.EXTERNAL_CONTENT_URI), + Pair(Environment.DIRECTORY_MOVIES, Video.Media.EXTERNAL_CONTENT_URI), + Pair(Environment.DIRECTORY_MUSIC, Audio.Media.EXTERNAL_CONTENT_URI), + ) + } + + override fun pendingLength(): Long { + return queryPendingUri(uniqueFilename())?.second ?: 0 + } + + override fun open(): OutputStream { + var uri = queryPendingUri(uniqueFilename())?.first + /* no pending file found, create a new one */ + if (uri == null) { + uri = createUri() + } + + return contentResolver.openOutputStream(uri, "wa") ?: throw FileNotFoundException() + } + + override fun finish() { + val uri = queryPendingUri(uniqueFilename())?.first ?: return + val contentValues = ContentValues() + contentValues.put(FileColumns.DISPLAY_NAME, requireFilename()) + contentValues.put(FileColumns.IS_PENDING, false) + contentResolver.update(uri, contentValues, null, null) + } + + private fun queryPendingUri(filename: String): Pair? { + val queryArgs = Bundle().apply { + if (VERSION.SDK_INT >= VERSION_CODES.R) { + putInt(MediaStore.QUERY_ARG_MATCH_PENDING, MediaStore.MATCH_ONLY) + } + putString(QUERY_ARG_SQL_SELECTION, FileColumns.DISPLAY_NAME + " = ?") + putStringArray(QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(filename)) + } + @Suppress("DEPRECATION") val uri = + if (VERSION.SDK_INT >= VERSION_CODES.R) contentUri + else MediaStore.setIncludePending(contentUri) + val cursor = contentResolver.query( + uri, + arrayOf(FileColumns._ID, FileColumns.DATA), + queryArgs, + null + ) + + if (cursor == null || cursor.count == 0 || !cursor.moveToFirst()) { + return null + } + + val id = cursor.getString(cursor.getColumnIndexOrThrow(FileColumns._ID)) + val path = cursor.getString(cursor.getColumnIndexOrThrow(FileColumns.DATA)) + cursor.close() + + return Pair(Uri.withAppendedPath(contentUri, id), File(path).length()) + } + + private fun createUri(): Uri { + val filename = uniqueFilename() + val contentValues = ContentValues() + contentValues.put(FileColumns.RELATIVE_PATH, Helper.resolvePath(type, subDir ?: "")) + contentValues.put(FileColumns.DISPLAY_NAME, filename) + contentValues.put(FileColumns.IS_PENDING, true) + val index = filename.lastIndexOf(".") + if (index > 0) { + val mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(filename.substring(index + 1)) + if (mimeType != null) { + contentValues.put(FileColumns.MIME_TYPE, mimeType) + } + } + return contentResolver.insert(contentUri, contentValues) + ?: throw IOException("Cannot open output stream for file: $filename") + } +} \ No newline at end of file diff --git a/library/src/main/java/com/coolerfall/download/Pack.kt b/library/src/main/java/com/coolerfall/download/Pack.kt new file mode 100644 index 0000000..c53ece2 --- /dev/null +++ b/library/src/main/java/com/coolerfall/download/Pack.kt @@ -0,0 +1,42 @@ +package com.coolerfall.download + +import java.io.OutputStream + +/** + * @author Vincent Cheung (coolingfall@gmail.com) + */ +abstract class Pack(private var filename: String? = null) { + + /** + * Open output stream for the given file in this pack. + * + * @return [OutputStream] + */ + internal abstract fun open(): OutputStream + + /** + * This pack is finished, move pending filename to real filename. + */ + internal abstract fun finish() + + /** + * Get the filename set in this pack. + * + * @return the target filename + * @throws [IllegalArgumentException] if filename is null + */ + protected fun requireFilename(): String { + return requireNotNull(filename) { "filename == null" } + } + + /** + * Put filename if absent. + */ + internal open fun putFilenameIfAbsent(filename: () -> String) { + if (this.filename != null) { + return + } + + this.filename = filename() + } +} \ No newline at end of file diff --git a/library/src/test/java/com/coolerfall/download/DownloadManagerTest.kt b/library/src/test/java/com/coolerfall/download/DownloadManagerTest.kt index 9341be9..b1d9545 100644 --- a/library/src/test/java/com/coolerfall/download/DownloadManagerTest.kt +++ b/library/src/test/java/com/coolerfall/download/DownloadManagerTest.kt @@ -27,7 +27,7 @@ class DownloadManagerTest { downloadManager = DownloadManager.get() request = Builder() .url(mockWebServer.url("/").toString()) - .relativeFilepath("/shadow/download.apk") + .target(ExtFilePack(filename = "/shadow/download.apk")) .build() } diff --git a/sample/src/main/java/com/coolerfall/fdroid/app/FDroidApplication.kt b/sample/src/main/java/com/coolerfall/fdroid/app/FDroidApplication.kt index a515056..0d54af3 100644 --- a/sample/src/main/java/com/coolerfall/fdroid/app/FDroidApplication.kt +++ b/sample/src/main/java/com/coolerfall/fdroid/app/FDroidApplication.kt @@ -1,10 +1,13 @@ package com.coolerfall.fdroid.app import android.app.Application +import com.coolerfall.download.DownloadManager import com.coolerfall.fdroid.BuildConfig import dagger.hilt.android.HiltAndroidApp import timber.log.Timber import timber.log.Timber.DebugTree +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.SECONDS /** * @author Vincent Cheung (coolingfall@gmail.com) @@ -18,5 +21,12 @@ class FDroidApplication : Application() { if (BuildConfig.DEBUG) { Timber.plant(DebugTree()) } + + DownloadManager.withBuilder( + DownloadManager.Builder() + .retryInterval(3, SECONDS) + .progressInterval(100, MICROSECONDS) + .logger { message -> Timber.i(message) } + ) } } \ No newline at end of file diff --git a/sample/src/main/java/com/coolerfall/fdroid/ui/AppInfoAdapter.kt b/sample/src/main/java/com/coolerfall/fdroid/ui/AppInfoAdapter.kt index 0a7fb21..36f2e7c 100644 --- a/sample/src/main/java/com/coolerfall/fdroid/ui/AppInfoAdapter.kt +++ b/sample/src/main/java/com/coolerfall/fdroid/ui/AppInfoAdapter.kt @@ -12,6 +12,8 @@ import com.coolerfall.download.DownloadRequest import com.coolerfall.download.DownloadState.PENDING import com.coolerfall.download.DownloadState.RUNNING import com.coolerfall.download.DownloadState.SUCCESSFUL +import com.coolerfall.download.ExtPublicPack +import com.coolerfall.download.Pack import com.coolerfall.fdroid.R import com.coolerfall.fdroid.data.model.AppInfo import com.coolerfall.fdroid.databinding.ItemAppInfoBinding @@ -89,6 +91,7 @@ class AppInfoAdapter : RecyclerView.Adapter() { } val request = DownloadRequest.Builder() .url(url) + .target(ExtPublicPack()) .downloadCallback(object : DownloadCallback { override fun onStart(downloadId: Int, totalBytes: Long) { progressMap[position] = 0 @@ -107,13 +110,15 @@ class AppInfoAdapter : RecyclerView.Adapter() { binding.indicator.setProgressCompat(progress, true) } - override fun onSuccess(downloadId: Int, filepath: String) { + override fun onSuccess(downloadId: Int, pack: Pack) { binding.indicator.hide() binding.btnToggle.visibility = View.INVISIBLE } override fun onFailure(downloadId: Int, statusCode: Int, errMsg: String?) { + progressMap[position] = 0 downloadMap.remove(position) + binding.btnToggle.setText(R.string.start) } }).build()