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:allowBackup="true"
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:icon="@mipmap/ic_logo" android:icon="@mipmap/ic_logo"
android:roundIcon="@mipmap/ic_logo_round"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_logo_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Comet"> android:theme="@style/Theme.Comet">
<activity <activity

View file

@ -17,11 +17,10 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
import dev.lowrespalmtree.comet.utils.joinUrls
import dev.lowrespalmtree.comet.utils.resolveLinkUri import dev.lowrespalmtree.comet.utils.resolveLinkUri
import dev.lowrespalmtree.comet.utils.toGeminiUri
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@ -147,14 +146,44 @@ class PageFragment : Fragment(), PageAdapter.Listener {
return return
when (event) { when (event) {
is PageViewModel.InputEvent -> { is PageViewModel.InputEvent -> {
askForInput(event.prompt, event.uri) InputDialog(requireContext(), event.prompt.ifEmpty { "Input required" })
updateState(PageViewModel.State.IDLE) .show(
onOk = { text ->
val newUri = event.uri.buildUpon().query(text).build()
openUrl(newUri.toString(), base = vm.currentUrl)
},
onDismiss = {}
)
} }
is PageViewModel.SuccessEvent -> { is PageViewModel.SuccessEvent -> {
vm.currentUrl = event.uri vm.currentUrl = event.uri
vm.visitedUrls.add(event.uri) vm.visitedUrls.add(event.uri)
binding.addressBar.setText(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 -> { is PageViewModel.RedirectEvent -> {
openUrl(event.uri, base = vm.currentUrl, redirections = event.redirects) openUrl(event.uri, base = vm.currentUrl, redirections = event.redirects)
} }
@ -165,7 +194,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
if (!isConnectedToNetwork(requireContext())) if (!isConnectedToNetwork(requireContext()))
message += "\n\nInternet may be inaccessible…" message += "\n\nInternet may be inaccessible…"
alert(message, title = event.short) alert(message, title = event.short)
updateState(PageViewModel.State.IDLE)
} }
} }
event.handled = true event.handled = true
@ -179,17 +207,6 @@ class PageFragment : Fragment(), PageAdapter.Listener {
.show() .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) { private fun openUnknownScheme(uri: Uri) {
try { try {
startActivity(Intent(Intent.ACTION_VIEW, uri)) startActivity(Intent(Intent.ACTION_VIEW, uri))

View file

@ -1,23 +1,35 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
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.resolveLinkUri import dev.lowrespalmtree.comet.utils.resolveLinkUri
import kotlinx.coroutines.* import kotlinx.coroutines.*
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(@Suppress("unused") private val savedStateHandle: SavedStateHandle) : class PageViewModel(
ViewModel() { @Suppress("unused") private val savedStateHandle: SavedStateHandle
) : ViewModel() {
/** Currently viewed page URL. */ /** Currently viewed page URL. */
var currentUrl: String = "" var currentUrl: String = ""
@ -42,14 +54,37 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
/** Lines for the current page. */ /** Lines for the current page. */
private var linesList = ArrayList<Line>() private var linesList = ArrayList<Line>()
/** Page state to be reflected on the UI (e.g. loading bar). */
enum class State { enum class State {
IDLE, CONNECTING, RECEIVING IDLE, CONNECTING, RECEIVING
} }
/** Generic event class to notify observers with. The handled flag avoids repeated usage. */
abstract class Event(var handled: Boolean = false) 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() 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() 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() 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( data class FailureEvent(
val short: String, val short: String,
val details: String, val details: String,
@ -59,7 +94,9 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
/** /**
* Perform a request against this URI. * 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 @ExperimentalCoroutinesApi
fun sendGeminiRequest( 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) { private fun signalError(message: String) {
event.postValue(FailureEvent("Error", message)) event.postValue(FailureEvent("Error", message))
state.postValue(State.IDLE)
} }
/** Notify observers that user input has been requested. */
private fun handleInputResponse(response: Response, uri: Uri) { private fun handleInputResponse(response: Response, uri: Uri) {
event.postValue(InputEvent(uri, response.meta)) event.postValue(InputEvent(uri, response.meta))
state.postValue(State.IDLE)
} }
/** Continue processing a successful response by looking at the provided MIME type. */
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
private suspend fun handleSuccessResponse(response: Response, uri: Uri) { private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
val mimeType = MimeType.from(response.meta) ?: MimeType.DEFAULT // Spec. section 3.3 last § 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 else
handleSuccessGenericTextResponse(response, uri) 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 @ExperimentalCoroutinesApi
private suspend fun handleSuccessGemtextResponse(response: Response, uri: Uri) { private suspend fun handleSuccessGemtextResponse(response: Response, uri: Uri) {
state.postValue(State.RECEIVING) state.postValue(State.RECEIVING)
@ -212,14 +255,21 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
state.postValue(State.IDLE) state.postValue(State.IDLE)
} }
/** Receive generic text data (e.g. text/plain) and send it to observers. */
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
private suspend fun handleSuccessGenericTextResponse(response: Response, uri: Uri) = private suspend fun handleSuccessGenericTextResponse(response: Response, uri: Uri) =
handleSuccessGemtextResponse(response, uri) // TODO render plain text as... something else? 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) { private fun handleRedirectResponse(response: Response, redirects: Int) {
event.postValue(RedirectEvent(response.meta, redirects)) 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) { private fun handleErrorResponse(response: Response) {
val briefMessage = when (response.code) { val briefMessage = when (response.code) {
Response.Code.TEMPORARY_FAILURE -> "40 Temporary failure" 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()) if (response.code != Response.Code.SLOW_DOWN && response.meta.isNotEmpty())
serverMessage = response.meta serverMessage = response.meta
event.postValue(FailureEvent(briefMessage, longMessage, serverMessage)) 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 { 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="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="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="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> </resources>