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

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"