PageViewModel: hande audio and video links
Big first step in issue #11.
This commit is contained in:
parent
11854d4312
commit
d9802dc44d
|
@ -1,31 +1,23 @@
|
|||
package dev.lowrespalmtree.comet
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import dev.lowrespalmtree.comet.utils.isPostQ
|
||||
import dev.lowrespalmtree.comet.utils.downloadMedia
|
||||
import dev.lowrespalmtree.comet.utils.resolveLinkUri
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.onSuccess
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.nio.charset.Charset
|
||||
import java.util.*
|
||||
import android.provider.MediaStore.Images.Media as ImagesMedia
|
||||
|
||||
class PageViewModel(
|
||||
@Suppress("unused") private val savedStateHandle: SavedStateHandle
|
||||
|
@ -173,7 +165,7 @@ class PageViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
/** Notify observers that an error happened. Set state to idle. */
|
||||
/** Notify observers that an error happened, with a generic short message. Set state to idle. */
|
||||
private fun signalError(message: String) {
|
||||
event.postValue(FailureEvent("Error", message))
|
||||
state.postValue(State.IDLE)
|
||||
|
@ -304,9 +296,7 @@ class PageViewModel(
|
|||
state.postValue(State.IDLE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download response content as a file.
|
||||
*/
|
||||
/** Download response content as a file. */
|
||||
@ExperimentalCoroutinesApi
|
||||
fun downloadResponse(
|
||||
channel: Channel<ByteArray>,
|
||||
|
@ -315,54 +305,20 @@ class PageViewModel(
|
|||
contentResolver: ContentResolver
|
||||
) {
|
||||
when (mimeType.main) {
|
||||
"image" -> downloadImage(channel, uri, mimeType, contentResolver)
|
||||
else -> throw UnsupportedOperationException() // TODO use SAF
|
||||
}
|
||||
}
|
||||
|
||||
/** Download image data in the media store. Run entirely in an IO coroutine. */
|
||||
private fun downloadImage(
|
||||
channel: Channel<ByteArray>,
|
||||
uri: Uri,
|
||||
mimeType: MimeType,
|
||||
contentResolver: ContentResolver
|
||||
) {
|
||||
val filename = uri.lastPathSegment.orEmpty().ifBlank { UUID.randomUUID().toString() }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// On Android Q and after, we use the proper MediaStore APIs.
|
||||
val mediaUri = if (isPostQ()) {
|
||||
val details = ContentValues().apply {
|
||||
put(ImagesMedia.IS_PENDING, 1)
|
||||
put(ImagesMedia.DISPLAY_NAME, filename)
|
||||
put(ImagesMedia.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/Comet")
|
||||
}
|
||||
val mediaUri = contentResolver.insert(ImagesMedia.EXTERNAL_CONTENT_URI, details)
|
||||
?: return@launch Unit.also {
|
||||
signalError("Download failed: can't create local media file.")
|
||||
}
|
||||
contentResolver.openOutputStream(mediaUri)?.use { os ->
|
||||
for (chunk in channel)
|
||||
os.write(chunk)
|
||||
} ?: return@launch Unit.also {
|
||||
signalError("Download failed: can't open output stream.")
|
||||
}
|
||||
details.clear()
|
||||
details.put(ImagesMedia.IS_PENDING, 0)
|
||||
contentResolver.update(mediaUri, details, null, null)
|
||||
mediaUri
|
||||
"image", "audio", "video" -> {
|
||||
downloadMedia(
|
||||
channel, uri, mimeType, viewModelScope, contentResolver,
|
||||
onSuccess = { mediaUri ->
|
||||
event.postValue(DownloadCompletedEvent(mediaUri, mimeType))
|
||||
state.postValue(State.IDLE)
|
||||
},
|
||||
onError = { msg -> signalError("Download failed: $msg") }
|
||||
)
|
||||
}
|
||||
// Before that, use the traditional clunky APIs. TODO test this stuff
|
||||
else {
|
||||
val collUri = ImagesMedia.EXTERNAL_CONTENT_URI
|
||||
val outputFile = File(File(collUri.toFile(), "Comet"), filename)
|
||||
FileOutputStream(outputFile).use { fos ->
|
||||
for (chunk in channel)
|
||||
fos.buffered().write(chunk)
|
||||
}
|
||||
outputFile.toUri()
|
||||
else -> {
|
||||
// TODO use SAF
|
||||
signalError("MIME type unsupported yet: ${mimeType.main} (\"${mimeType.short}\")")
|
||||
}
|
||||
event.postValue(DownloadCompletedEvent(mediaUri, mimeType))
|
||||
state.postValue(State.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
124
app/src/main/java/dev/lowrespalmtree/comet/utils/Media.kt
Normal file
124
app/src/main/java/dev/lowrespalmtree/comet/utils/Media.kt
Normal file
|
@ -0,0 +1,124 @@
|
|||
package dev.lowrespalmtree.comet.utils
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import dev.lowrespalmtree.comet.MimeType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Download media data from a ByteArray channel, into the media store.
|
||||
*
|
||||
* Run entirely in an IO coroutine. Convoluted because it tries to work properly with the Android 10
|
||||
* MediaStore API and be backward-compatible as well. Still unsure it succeeds with both.
|
||||
*
|
||||
* @param channel incoming bytes of server response
|
||||
* @param uri URI corresponding to the response
|
||||
* @param mimeType MIME type parsed from the response's meta; MUST be either image, audio or video
|
||||
* @param scope CoroutineScope to use for launching the download job
|
||||
* @param contentResolver ContentResolver for MediaStore access
|
||||
* @param onSuccess callback on completed download, with the saved media URI
|
||||
* @param onError callback on error with a short message to show
|
||||
*/
|
||||
fun downloadMedia(
|
||||
channel: Channel<ByteArray>,
|
||||
uri: Uri,
|
||||
mimeType: MimeType,
|
||||
scope: CoroutineScope,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (mediaUri: Uri) -> Unit,
|
||||
onError: (message: String) -> Unit,
|
||||
) {
|
||||
val filename = uri.lastPathSegment.orEmpty().ifBlank { UUID.randomUUID().toString() }
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// On Android Q and after, we use the proper MediaStore APIs. Proper…
|
||||
val filetype = mimeType.main.also { assert(it in listOf("image", "audio", "video")) }
|
||||
val mediaUri: Uri
|
||||
if (isPostQ()) {
|
||||
val details = ContentValues().apply {
|
||||
put(getIsPendingCV(filetype), 1)
|
||||
put(getDisplayNameCV(filetype), filename)
|
||||
put(getRelativePathCV(filetype), getRelativePath(filetype))
|
||||
}
|
||||
mediaUri = contentResolver.insert(getContentUri(filetype), details)
|
||||
?: return@launch Unit.also { onError("can't create local media file") }
|
||||
contentResolver.openOutputStream(mediaUri)?.use { os ->
|
||||
for (chunk in channel)
|
||||
os.write(chunk)
|
||||
} ?: return@launch Unit.also { onError("can't open output stream") }
|
||||
details.clear()
|
||||
details.put(getIsPendingCV(filetype), 0)
|
||||
contentResolver.update(mediaUri, details, null, null)
|
||||
}
|
||||
// Before that, use the traditional clunky APIs. TODO test this stuff
|
||||
else {
|
||||
val collUri = getContentUri(filetype)
|
||||
val outputFile = File(File(collUri.toFile(), "Comet"), filename)
|
||||
FileOutputStream(outputFile).use { fos ->
|
||||
for (chunk in channel)
|
||||
fos.buffered().write(chunk)
|
||||
}
|
||||
mediaUri = outputFile.toUri()
|
||||
}
|
||||
onSuccess(mediaUri)
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the default external content URI for this file type. */
|
||||
private fun getContentUri(type: String) =
|
||||
when (type) {
|
||||
"image" -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
"audio" -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
"video" -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/** Get the display name *content value string identifier* for this file type. */
|
||||
private fun getDisplayNameCV(type: String) =
|
||||
when (type) {
|
||||
"image" -> MediaStore.Images.Media.DISPLAY_NAME
|
||||
"audio" -> MediaStore.Audio.Media.DISPLAY_NAME
|
||||
"video" -> MediaStore.Video.Media.DISPLAY_NAME
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/** Get the isPending flag *content value string identifier* for this file type. */
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun getIsPendingCV(type: String) =
|
||||
when (type) {
|
||||
"image" -> MediaStore.Images.Media.IS_PENDING
|
||||
"audio" -> MediaStore.Audio.Media.IS_PENDING
|
||||
"video" -> MediaStore.Video.Media.IS_PENDING
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/** Get the relative path *content value string identifier* for this file type. */
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun getRelativePathCV(type: String) =
|
||||
when (type) {
|
||||
"image" -> MediaStore.Images.Media.RELATIVE_PATH
|
||||
"audio" -> MediaStore.Audio.Media.RELATIVE_PATH
|
||||
"video" -> MediaStore.Video.Media.RELATIVE_PATH
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/** Get the actual relative path for this file type, usually standard with a "Comet" subfolder. */
|
||||
private fun getRelativePath(type: String) =
|
||||
when (type) {
|
||||
"image" -> Environment.DIRECTORY_PICTURES
|
||||
"audio" -> Environment.DIRECTORY_MUSIC // TODO should be a user choice
|
||||
"video" -> Environment.DIRECTORY_MOVIES
|
||||
else -> throw UnsupportedOperationException()
|
||||
} + "/Comet"
|
Reference in a new issue