Article summary
Giving a user the ability to download files in your app can be difficult to figure out. In iOS, you can use AlamoFire to download the file locally and then present it with a UIDocumentInteractionController. (The code would look something like this.) It presents the documents, images, gifs, videos, etc. in the app for you, and you can then download to the device from there.
Unfortunately, this isn’t simple in Android because there are many OEMs for Android devices. (In-app image viewing can be handled using Glide. If you absolutely need to view PDFs in-app, you can probably find some solution, but I would recommend just giving the users download ability and letting their device handle it.) Today, I’ll explain how to download files in Kotlin using Ktor and intents.
Initial Setup
The first thing you will need are some dependencies, Ktor, and coroutines. Add these to your app Gradle. Ktor allows for asynchronous communication, which is very useful for file downloading and reporting file progress.
dependencies {
...
implementation "io.ktor:ktor-client-android:1.2.5"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
...
}
Then we need to add this file into res.xml. (You’ll likely need to create the xml resources folder.) It adds an external path.
<paths>
<external-path name="external_files" path="."/>
</paths>
In the AndroidManifest, make sure to add these permissions:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
Add the provider. This uses the external path we defined above for the FileProvider.
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/external_files"/>
</provider>
Downloader Coroutine
The coroutine for downloading files will be an extension on Ktor’s HttpClient. First, we need a class to return during the coroutine to report on the status of the download.
sealed class DownloadResult {
object Success : DownloadResult()
data class Error(val message: String, val cause: Exception? = null) : DownloadResult()
data class Progress(val progress: Int): DownloadResult()
}
This extension creates a coroutine that takes an output stream and URL. While the file is read in, the current progress is emitted. Once finished, the data is written to the output stream, and success is returned. Otherwise, there was some failure.
suspend fun HttpClient.downloadFile(file: OutputStream, url: String): Flow<DownloadResult> {
return flow {
try {
val response = call {
url(url)
method = HttpMethod.Get
}.response
val data = ByteArray(response.contentLength()!!.toInt())
var offset = 0
do {
val currentRead = response.content.readAvailable(data, offset, data.size)
offset += currentRead
val progress = (offset * 100f / data.size).roundToInt()
emit(DownloadResult.Progress(progress))
} while (currentRead > 0)
response.close()
if (response.status.isSuccess()) {
withContext(Dispatchers.IO) {
file.write(data)
}
emit(DownloadResult.Success)
} else {
emit(DownloadResult.Error("File not downloaded"))
}
} catch (e: TimeoutCancellationException) {
emit(DownloadResult.Error("Connection timed out", e))
} catch (t: Throwable) {
emit(DownloadResult.Error("Failed to connect"))
}
}
}
This code was originally found on Kotlin Academy.
The ViewModel and Layout
Before making the fragment, we need a view model and a layout. The view model is simple, only containing a Boolean to indicate if the download is occurring.
class MainViewModel : ViewModel() {
private val _downloading: MutableLiveData<Boolean> = MutableLiveData()
val downloading: LiveData<Boolean> = _downloading
fun setDownloading(downloading: Boolean) {
_downloading.value = downloading
}
}
The layout itself is also simple, just a button (which is enabled when the file isn’t downloading) and a horizontal progress bar. Note that material-style circle progress bars can’t have progress set and continually spin; they’re more useful for other types of requests.
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="viewModel"
type="com.example.kotlin_file_download.MainViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/view_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:layout_marginEnd="40dp"
android:text="Download File"
android:enabled="@{!safeUnbox(viewModel.downloading)}"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@+id/progress_bar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progress_bar"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:indeterminate="false"
android:max="100"
android:progress="0"
android:progressTint="@color/colorPrimary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/view_button" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
The Fragment
The base of the fragment is straightforward enough. Of note are the permissions and codes: if the app doesn’t have these permissions, the app can’t download files. The codes don’t need to be “1” and “2,” but if there are others in the app, they should all be unique.
When the fragment loads, if we have permissions, we can set the button click listener; otherwise, we have to request permission. Some apps request these permissions on first load if they require them throughout the app. Others only request them where they are actually needed. If your app has a specific place where file downloads occur, you can just request permission there.
class MainFragment : Fragment() {
private lateinit var binding: FragmentMainBinding
private lateinit var viewModel: MainViewModel
private val PERMISSIONS = listOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)
private val PERMISSION_REQUEST_CODE = 1
private val DOWNLOAD_FILE_CODE = 2
private val fileUrl = "https://d2v9y0dukr6mq2.cloudfront.net/video/thumbnail/rcxbst_b0itvu9rs2/kitten-in-a-cup-turns-its-head-and-watches_raeb_02je_thumbnail-full01.png"
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_main,
container,
false
)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
if (hasPermissions(context, PERMISSIONS)) {
setDownloadButtonClickListener()
} else {
requestPermissions(PERMISSIONS.toTypedArray(), PERMISSION_REQUEST_CODE)
}
}
...
}
First, we need functions to check the permissions and the request result. Note that only devices with Marshmallow (API 23) or later need permissions; earlier devices had them by default.
private fun hasPermissions(context: Context?, permissions: List<String>): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && context != null) {
return permissions.all { permission ->
ActivityCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
}
return true
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE && hasPermissions(context, PERMISSIONS)) {
setDownloadButtonClickListener()
}
}
If permission needs to be requested, our return should set the click listener when permission is granted. Either you can force the download to occur where you want with the name you provide, or you can let the user decide the name and location. I use the latter case here; the click will start an intent for creating a document with some defaults set.
private fun setDownloadButtonClickListener() {
val folder = context?.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
val fileName = "kitten_in_a_cup.png"
val file = File(folder, fileName)
val uri = context?.let {
FileProvider.getUriForFile(it, "${BuildConfig.APPLICATION_ID}.provider", file)
}
val extension = MimeTypeMap.getFileExtensionFromUrl(uri?.path)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
binding.viewButton.setOnClickListener {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.setDataAndType(uri, mimeType)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
intent.putExtra(Intent.EXTRA_TITLE, fileName)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
intent.addCategory(Intent.CATEGORY_OPENABLE)
startActivityForResult(intent, DOWNLOAD_FILE_CODE)
}
}
The result will use the returned URI as the location for downloading the file.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == DOWNLOAD_FILE_CODE && resultCode == Activity.RESULT_OK) {
data?.data?.let { uri ->
context?.let { context ->
downloadFile(context, fileUrl, uri)
}
}
}
}
Downloading the file opens the output stream to the URI given and dispatches the download file coroutine. The download itself is handled on the IO thread, but the emitter results are handled on the Main thread. This allows for the correct asynchronous updates (in this case, updating the progress bar).
private fun downloadFile(context: Context, url: String, file: Uri) {
val ktor = HttpClient(Android)
viewModel.setDownloading(true)
context.contentResolver.openOutputStream(file)?.let { outputStream ->
CoroutineScope(Dispatchers.IO).launch {
ktor.downloadFile(outputStream, url).collect {
withContext(Dispatchers.Main) {
when (it) {
is DownloadResult.Success -> {
viewModel.setDownloading(false)
binding.progressBar.progress = 0
viewFile(file)
}
is DownloadResult.Error -> {
viewModel.setDownloading(false)
Toast.makeText(
context,
"Error while downloading file",
Toast.LENGTH_LONG
).show()
}
is DownloadResult.Progress -> {
binding.progressBar.progress = it.progress
}
}
}
}
}
}
}
The viewFile function takes the same URI given and opens it in an intent, if able. If there are multiple applications where the file can be viewed, it presents a chooser.
private fun viewFile(uri: Uri) {
context?.let { context ->
val intent = Intent(Intent.ACTION_VIEW, uri)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val chooser = Intent.createChooser(intent, "Open with")
if (intent.resolveActivity(context.packageManager) != null) {
startActivity(chooser)
} else {
Toast.makeText(context, "No suitable application to open file", Toast.LENGTH_LONG).show()
}
}
}
The code for this can be found on my Github repo: kotlin-file-downloading.
iOS Code Sample
func downloadFile(fileURL: URL, dispatchQueue: DispatchQueue) {
viewButton?.isEnabled = false
startActivityIndicator()
let destination: DownloadRequest.DownloadFileDestination = { _, _ in
let documentsURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
if let name = self.document?.name, let type = self.document?.fileType?.lowercased() {
let fileURL = documentsURL.appendingPathComponent("\(name).\(type)")
return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
}
let fileURL = documentsURL.appendingPathComponent(fileURL.lastPathComponent)
return (fileURL, [.removePreviousFile, .createIntermediateDirectories])
}
print("downloading file \(fileURL.absoluteString)")
Alamofire.download(fileURL, to: destination).response(queue: dispatchQueue) { response in
DispatchQueue.main.async {
self.stopActivityIndicator()
self.viewButton?.isEnabled = true
if let error = response.error {
self.handleCommonAPIErrors(error, alertPresenter: self.alertPresenter)
} else if let fileUrl = response.destinationURL {
self.docController = UIDocumentInteractionController(url: fileUrl)
self.docController!.delegate = self
if !self.docController!.presentPreview(animated: true) {
self.fileAvailableForViewing(viewable: false)
}
} else {
self.alertPresenter.alertMessage(AlertText.failedDownload, title: AlertText.genericErrorTitle,
onTopOf: self, buttonTitle: nil, handler: nil)
}
}
}
}
This implementation has one issue. I can download file in the first time but next to second file, it’s fail with Exception: `java.net.ProtocolException: Unexpected status line:`. It’s invoked from `catch (t: Throwable)`
For the iOS sample, are you using ktor at all for that? I am thinking of making an AssetDownloader for both my iOS and Android versions of the app and I’d like to have both apps use it as a kotlin multiplatform library. Wondering if this is possible with ktor
No, this was just Alamofire. Can’t help you there I’m afraid.