PageViewModel: download images to media store

This commit is contained in:
dece 2022-02-16 23:36:35 +01:00
parent d447370a41
commit 11854d4312
5 changed files with 160 additions and 21 deletions

View file

@ -9,8 +9,8 @@
android:allowBackup="true"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_logo"
android:roundIcon="@mipmap/ic_logo_round"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_logo_round"
android:supportsRtl="true"
android:theme="@style/Theme.Comet">
<activity

View file

@ -17,11 +17,10 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
import dev.lowrespalmtree.comet.utils.joinUrls
import dev.lowrespalmtree.comet.utils.resolveLinkUri
import dev.lowrespalmtree.comet.utils.toGeminiUri
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi
@ -147,14 +146,44 @@ class PageFragment : Fragment(), PageAdapter.Listener {
return
when (event) {
is PageViewModel.InputEvent -> {
askForInput(event.prompt, event.uri)
updateState(PageViewModel.State.IDLE)
InputDialog(requireContext(), event.prompt.ifEmpty { "Input required" })
.show(
onOk = { text ->
val newUri = event.uri.buildUpon().query(text).build()
openUrl(newUri.toString(), base = vm.currentUrl)
},
onDismiss = {}
)
}
is PageViewModel.SuccessEvent -> {
vm.currentUrl = event.uri
vm.visitedUrls.add(event.uri)
binding.addressBar.setText(event.uri)
}
is PageViewModel.BinaryEvent -> {
// TODO this should present the user with options on what to do according to the
// MIME type: show inline, save in the media store, save as generic download, etc.
vm.downloadResponse(
event.response.data,
event.uri,
event.mimeType,
requireContext().contentResolver
)
}
is PageViewModel.DownloadCompletedEvent -> {
val message = when (event.mimeType.main) {
"image" -> R.string.image_download_completed
else -> R.string.download_completed
}
Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT)
.setAction(R.string.open) {
startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(event.uri, event.mimeType.short)
})
}
.show()
vm.visitedUrls.add(event.uri.toString())
}
is PageViewModel.RedirectEvent -> {
openUrl(event.uri, base = vm.currentUrl, redirections = event.redirects)
}
@ -165,7 +194,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
if (!isConnectedToNetwork(requireContext()))
message += "\n\nInternet may be inaccessible…"
alert(message, title = event.short)
updateState(PageViewModel.State.IDLE)
}
}
event.handled = true
@ -179,17 +207,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
.show()
}
private fun askForInput(prompt: String, uri: Uri) {
InputDialog(requireContext(), prompt.ifEmpty { "Input required" })
.show(
onOk = { text ->
val newUri = uri.buildUpon().query(text).build()
openUrl(newUri.toString(), base = vm.currentUrl)
},
onDismiss = {}
)
}
private fun openUnknownScheme(uri: Uri) {
try {
startActivity(Intent(Intent.ACTION_VIEW, uri))

View file

@ -1,23 +1,35 @@
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.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) :
ViewModel() {
class PageViewModel(
@Suppress("unused") private val savedStateHandle: SavedStateHandle
) : ViewModel() {
/** Currently viewed page URL. */
var currentUrl: String = ""
@ -42,14 +54,37 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
/** Lines for the current page. */
private var linesList = ArrayList<Line>()
/** Page state to be reflected on the UI (e.g. loading bar). */
enum class State {
IDLE, CONNECTING, RECEIVING
}
/** Generic event class to notify observers with. The handled flag avoids repeated usage. */
abstract class Event(var handled: Boolean = false)
/** An user input has been requested from the URI, with this prompt. */
data class InputEvent(val uri: Uri, val prompt: String) : Event()
/** The server responded with a success code and *has finished* its response. */
data class SuccessEvent(val uri: String) : Event()
/** The server responded with a success code and a binary MIME type (not delivered yet). */
data class BinaryEvent(
val uri: Uri,
val response: Response,
val mimeType: MimeType
) : Event()
/** A file has been completely downloaded. */
data class DownloadCompletedEvent(
val uri: Uri,
val mimeType: MimeType
) : Event()
/** The server is redirecting us. */
data class RedirectEvent(val uri: String, val redirects: Int) : Event()
/** The server responded with a failure code or we encountered a local issue. */
data class FailureEvent(
val short: String,
val details: String,
@ -59,7 +94,9 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
/**
* Perform a request against this URI.
*
* The URI must be valid, absolute and with a gemini scheme.
* @param uri URI to open; must be valid, absolute and with a gemini scheme
* @param context Context used to retrieve user preferences, not stored
* @param redirects current number of redirections operated
*/
@ExperimentalCoroutinesApi
fun sendGeminiRequest(
@ -136,14 +173,19 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
}
}
/** Notify observers that an error happened. Set state to idle. */
private fun signalError(message: String) {
event.postValue(FailureEvent("Error", message))
state.postValue(State.IDLE)
}
/** Notify observers that user input has been requested. */
private fun handleInputResponse(response: Response, uri: Uri) {
event.postValue(InputEvent(uri, response.meta))
state.postValue(State.IDLE)
}
/** Continue processing a successful response by looking at the provided MIME type. */
@ExperimentalCoroutinesApi
private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
val mimeType = MimeType.from(response.meta) ?: MimeType.DEFAULT // Spec. section 3.3 last §
@ -154,10 +196,11 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
else
handleSuccessGenericTextResponse(response, uri)
}
else -> signalError("No idea how to process a \"${mimeType.short}\" file.")
else -> event.postValue(BinaryEvent(uri, response, mimeType))
}
}
/** Receive Gemtext data, parse it and send the lines to observers. */
@ExperimentalCoroutinesApi
private suspend fun handleSuccessGemtextResponse(response: Response, uri: Uri) {
state.postValue(State.RECEIVING)
@ -212,14 +255,21 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
state.postValue(State.IDLE)
}
/** Receive generic text data (e.g. text/plain) and send it to observers. */
@ExperimentalCoroutinesApi
private suspend fun handleSuccessGenericTextResponse(response: Response, uri: Uri) =
handleSuccessGemtextResponse(response, uri) // TODO render plain text as... something else?
/** Notify observers that a redirect has been returned. */
private fun handleRedirectResponse(response: Response, redirects: Int) {
event.postValue(RedirectEvent(response.meta, redirects))
}
/**
* Provide an error message to the user corresponding to the error code.
*
* TODO This requires a lot of localisation.
*/
private fun handleErrorResponse(response: Response) {
val briefMessage = when (response.code) {
Response.Code.TEMPORARY_FAILURE -> "40 Temporary failure"
@ -251,6 +301,69 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
if (response.code != Response.Code.SLOW_DOWN && response.meta.isNotEmpty())
serverMessage = response.meta
event.postValue(FailureEvent(briefMessage, longMessage, serverMessage))
state.postValue(State.IDLE)
}
/**
* Download response content as a file.
*/
@ExperimentalCoroutinesApi
fun downloadResponse(
channel: Channel<ByteArray>,
uri: Uri,
mimeType: MimeType,
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
}
// 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()
}
event.postValue(DownloadCompletedEvent(mediaUri, mimeType))
state.postValue(State.IDLE)
}
}
companion object {

View file

@ -0,0 +1,6 @@
package dev.lowrespalmtree.comet.utils
import android.os.Build
/** Return true if the device is running Android 10 ("Q") or higher. */
fun isPostQ() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q

View file

@ -47,5 +47,8 @@
<string name="identity_usages">Active URL path</string>
<string name="input_common_name">Enter a name to use as the certificate\'s subject common name. This can be left empty.</string>
<string name="tls_version">TLS version</string>
<string name="open">Open</string>
<string name="download_completed">File downloaded.</string>
<string name="image_download_completed">Image downloaded.</string>
</resources>