PageViewModel: hande audio and video links

Big first step in issue #11.
This commit is contained in:
dece 2022-02-17 00:32:20 +01:00
parent 11854d4312
commit d9802dc44d
2 changed files with 139 additions and 59 deletions

View file

@ -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)
}
}

View 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"