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
|
package dev.lowrespalmtree.comet
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Environment
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toFile
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import dev.lowrespalmtree.comet.utils.isPostQ
|
import dev.lowrespalmtree.comet.utils.downloadMedia
|
||||||
import dev.lowrespalmtree.comet.utils.resolveLinkUri
|
import dev.lowrespalmtree.comet.utils.resolveLinkUri
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.onSuccess
|
import kotlinx.coroutines.channels.onSuccess
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.util.*
|
|
||||||
import android.provider.MediaStore.Images.Media as ImagesMedia
|
|
||||||
|
|
||||||
class PageViewModel(
|
class PageViewModel(
|
||||||
@Suppress("unused") private val savedStateHandle: SavedStateHandle
|
@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) {
|
private fun signalError(message: String) {
|
||||||
event.postValue(FailureEvent("Error", message))
|
event.postValue(FailureEvent("Error", message))
|
||||||
state.postValue(State.IDLE)
|
state.postValue(State.IDLE)
|
||||||
|
@ -304,9 +296,7 @@ class PageViewModel(
|
||||||
state.postValue(State.IDLE)
|
state.postValue(State.IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Download response content as a file. */
|
||||||
* Download response content as a file.
|
|
||||||
*/
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
fun downloadResponse(
|
fun downloadResponse(
|
||||||
channel: Channel<ByteArray>,
|
channel: Channel<ByteArray>,
|
||||||
|
@ -315,54 +305,20 @@ class PageViewModel(
|
||||||
contentResolver: ContentResolver
|
contentResolver: ContentResolver
|
||||||
) {
|
) {
|
||||||
when (mimeType.main) {
|
when (mimeType.main) {
|
||||||
"image" -> downloadImage(channel, uri, mimeType, contentResolver)
|
"image", "audio", "video" -> {
|
||||||
else -> throw UnsupportedOperationException() // TODO use SAF
|
downloadMedia(
|
||||||
}
|
channel, uri, mimeType, viewModelScope, contentResolver,
|
||||||
}
|
onSuccess = { mediaUri ->
|
||||||
|
event.postValue(DownloadCompletedEvent(mediaUri, mimeType))
|
||||||
/** Download image data in the media store. Run entirely in an IO coroutine. */
|
state.postValue(State.IDLE)
|
||||||
private fun downloadImage(
|
},
|
||||||
channel: Channel<ByteArray>,
|
onError = { msg -> signalError("Download failed: $msg") }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
// Before that, use the traditional clunky APIs. TODO test this stuff
|
else -> {
|
||||||
else {
|
// TODO use SAF
|
||||||
val collUri = ImagesMedia.EXTERNAL_CONTENT_URI
|
signalError("MIME type unsupported yet: ${mimeType.main} (\"${mimeType.short}\")")
|
||||||
val outputFile = File(File(collUri.toFile(), "Comet"), filename)
|
|
||||||
FileOutputStream(outputFile).use { fos ->
|
|
||||||
for (chunk in channel)
|
|
||||||
fos.buffered().write(chunk)
|
|
||||||
}
|
|
||||||
outputFile.toUri()
|
|
||||||
}
|
}
|
||||||
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