From 11854d43126a107ec3c9d4c8419acdcc63e65c53 Mon Sep 17 00:00:00 2001 From: dece Date: Wed, 16 Feb 2022 23:36:35 +0100 Subject: [PATCH] PageViewModel: download images to media store --- app/src/main/AndroidManifest.xml | 2 +- .../dev/lowrespalmtree/comet/PageFragment.kt | 49 ++++--- .../dev/lowrespalmtree/comet/PageViewModel.kt | 121 +++++++++++++++++- .../lowrespalmtree/comet/utils/Platform.kt | 6 + app/src/main/res/values/strings.xml | 3 + 5 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/dev/lowrespalmtree/comet/utils/Platform.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c87e75a..003be41 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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"> { - 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)) diff --git a/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt b/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt index cda8c30..e363c47 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/PageViewModel.kt @@ -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() + /** 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, + 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, + 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 { diff --git a/app/src/main/java/dev/lowrespalmtree/comet/utils/Platform.kt b/app/src/main/java/dev/lowrespalmtree/comet/utils/Platform.kt new file mode 100644 index 0000000..fc46b07 --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/utils/Platform.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b5adca5..e84e570 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,5 +47,8 @@ Active URL path Enter a name to use as the certificate\'s subject common name. This can be left empty. TLS version + Open + File downloaded. + Image downloaded. \ No newline at end of file