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()