PageViewModel: download images to media store
This commit is contained in:
parent
d447370a41
commit
11854d4312
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
|
@ -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>
|
Reference in a new issue