diff --git a/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt b/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt index e363c47..c21bde1 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt @@ -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, @@ -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, - 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) } } diff --git a/app/src/main/java/dev/lowrespalmtree/comet/utils/Media.kt b/app/src/main/java/dev/lowrespalmtree/comet/utils/Media.kt new file mode 100644 index 0000000..10037e2 --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/utils/Media.kt @@ -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, + 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" \ No newline at end of file