Compare commits

...

8 commits

Author SHA1 Message Date
dece d9802dc44d PageViewModel: hande audio and video links
Big first step in issue #11.
2022-02-17 00:46:07 +01:00
dece 11854d4312 PageViewModel: download images to media store 2022-02-16 23:36:35 +01:00
dece d447370a41 MimeType: use it to process responses accordingly
This prevents Comet from showing JPGs as Gemtext, whew… Any unhandled
MIME type now presents an alert, and we should slowly support more and
more types.
2022-02-16 11:48:23 +01:00
dece a76e84cf1d UriUtilsTest: clean 2022-02-16 11:06:52 +01:00
dece fd5471b615 Uri: add resolveLinkUri with tests 2022-02-16 11:06:26 +01:00
dece fcf12f09d2 Request: fix coroutine pool for server data 2022-02-15 17:09:25 +01:00
dece 59e664ec5c clean and add some docs 2022-02-15 17:09:11 +01:00
dece cd1732c599 MimeType: add class with tests, woah! 2022-02-15 17:08:39 +01:00
11 changed files with 431 additions and 69 deletions

View file

@ -1,48 +1,71 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import dev.lowrespalmtree.comet.utils.*
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
/** /** Test utils.Uri functions. Runs of the device due to the Uri functions being Android-only .*/
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class UriUtilsTest { class UriUtilsTest {
@Test
fun resolveLinkUri() {
// Absolute URLs.
assertEquals(
"gemini://example.com/",
resolveLinkUri("gemini://example.com", "gemini://dece.space/").toString()
)
// Relative links.
assertEquals(
"gemini://example.com/",
resolveLinkUri(".", "gemini://example.com/").toString()
)
assertEquals(
"gemini://example.com/",
resolveLinkUri("..", "gemini://example.com/").toString()
)
assertEquals(
"gemini://example.com/page",
resolveLinkUri("./page", "gemini://example.com/").toString()
)
assertEquals(
"gemini://example.com/page",
resolveLinkUri("page", "gemini://example.com/").toString()
)
assertEquals(
"gemini://example.com/page.com",
resolveLinkUri("page.com", "gemini://example.com/").toString()
)
// Scheme-less URLs.
assertEquals(
"gemini://someone.smol.pub/somepage",
resolveLinkUri("//someone.smol.pub/somepage", "gemini://smol.pub/feed").toString()
)
}
@Test @Test
fun joinUrls() { fun joinUrls() {
assertEquals( assertEquals(
"gemini://dece.space/some-file.gmi", "gemini://dece.space/some-file.gmi",
dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/", "some-file.gmi").toString() joinUrls("gemini://dece.space/", "some-file.gmi").toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/some-file.gmi", "gemini://dece.space/some-file.gmi",
dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/", "./some-file.gmi").toString() joinUrls("gemini://dece.space/", "./some-file.gmi").toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/some-file.gmi", "gemini://dece.space/some-file.gmi",
dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString() joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/dir1/other-file.gmi", "gemini://dece.space/dir1/other-file.gmi",
dev.lowrespalmtree.comet.utils.joinUrls( joinUrls("gemini://dece.space/dir1/file.gmi", "other-file.gmi").toString()
"gemini://dece.space/dir1/file.gmi",
"other-file.gmi"
).toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/top-level.gmi", "gemini://dece.space/top-level.gmi",
dev.lowrespalmtree.comet.utils.joinUrls( joinUrls("gemini://dece.space/dir1/file.gmi", "../top-level.gmi").toString()
"gemini://dece.space/dir1/file.gmi",
"../top-level.gmi"
).toString()
) )
assertEquals( assertEquals(
"s://hard/test/b/d/a.gmi", "s://hard/test/b/d/a.gmi",
dev.lowrespalmtree.comet.utils.joinUrls( joinUrls("s://hard/dir/a", "./../test/b/c/../d/e/f/../.././a.gmi").toString()
"s://hard/dir/a",
"./../test/b/c/../d/e/f/../.././a.gmi"
).toString()
) )
} }
@ -58,28 +81,28 @@ class UriUtilsTest {
Pair("mid/content=5/../6", "mid/6"), Pair("mid/content=5/../6", "mid/6"),
Pair("../../../../g", "g") Pair("../../../../g", "g")
).forEach { (path, expected) -> ).forEach { (path, expected) ->
assertEquals(expected, dev.lowrespalmtree.comet.utils.removeDotSegments(path)) assertEquals(expected, removeDotSegments(path))
} }
} }
@Test @Test
fun removeLastSegment() { fun removeLastSegment() {
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("")) assertEquals("", removeLastSegment(""))
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/")) assertEquals("", removeLastSegment("/"))
assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/a")) assertEquals("", removeLastSegment("/a"))
assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/")) assertEquals("/a", removeLastSegment("/a/"))
assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b")) assertEquals("/a", removeLastSegment("/a/b"))
assertEquals("/a/b/c", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b/c/d")) assertEquals("/a/b/c", removeLastSegment("/a/b/c/d"))
assertEquals("//", dev.lowrespalmtree.comet.utils.removeLastSegment("///")) assertEquals("//", removeLastSegment("///"))
} }
@Test @Test
fun popFirstSegment() { fun popFirstSegment() {
assertEquals(Pair("", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("")) assertEquals(Pair("", ""), popFirstSegment(""))
assertEquals(Pair("a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("a")) assertEquals(Pair("a", ""), popFirstSegment("a"))
assertEquals(Pair("/a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("/a")) assertEquals(Pair("/a", ""), popFirstSegment("/a"))
assertEquals(Pair("/a", "/"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/")) assertEquals(Pair("/a", "/"), popFirstSegment("/a/"))
assertEquals(Pair("/a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/b")) assertEquals(Pair("/a", "/b"), popFirstSegment("/a/b"))
assertEquals(Pair("a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("a/b")) assertEquals(Pair("a", "/b"), popFirstSegment("a/b"))
} }
} }

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

@ -0,0 +1,37 @@
package dev.lowrespalmtree.comet
class MimeType(
val main: String,
val sub: String,
val params: Map<String, String>
) {
val short: String get() = "${main.ifEmpty { "*" }}/${sub.ifEmpty { "*" }}"
val charset: String get() = params.getOrDefault("charset", DEFAULT_CHARSET)
companion object {
const val DEFAULT_CHARSET = "utf-8"
val DEFAULT = MimeType("text", "gemini", mapOf("charset" to DEFAULT_CHARSET))
fun from(string: String): MimeType? {
val typeString: String
val params: Map<String, String>
if (";" in string) {
val elements = string.split(";")
typeString = elements[0]
params = mutableMapOf()
elements.subList(1, elements.size)
.map { it.trim().lowercase() }
.map { p -> if (p.count { it == '=' } != 1) return@from null else p }
.map { it.split('=') }
.forEach { params[it[0]] = it[1] }
} else {
typeString = string.trim()
params = mapOf()
}
if (typeString.count { it == '/' } != 1)
return null
val (main, sub) = typeString.split('/').map { it.trim() }
return MimeType(main, sub, params)
}
}
}

View file

@ -17,10 +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.toGeminiUri
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@ -107,13 +107,7 @@ class PageFragment : Fragment(), PageAdapter.Listener {
return return
} }
var uri = Uri.parse(url) val uri = resolveLinkUri(url, base)
if (!uri.isAbsolute) {
uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri)
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
uri = uri.buildUpon().path("/").build()
}
when (uri.scheme) { when (uri.scheme) {
"gemini" -> vm.sendGeminiRequest(uri, requireContext()) "gemini" -> vm.sendGeminiRequest(uri, requireContext())
else -> openUnknownScheme(uri) else -> openUnknownScheme(uri)
@ -152,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)
} }
@ -170,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
@ -184,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,5 +1,6 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
@ -8,16 +9,19 @@ 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.joinUrls import dev.lowrespalmtree.comet.utils.downloadMedia
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.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
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 +46,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 +86,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,16 +165,36 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
} }
} }
/** Notify observers that an error happened, with a generic short message. 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 §
when (mimeType.main) {
"text" -> {
if (mimeType.sub == "gemini")
handleSuccessGemtextResponse(response, uri)
else
handleSuccessGenericTextResponse(response, uri)
}
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) state.postValue(State.RECEIVING)
val uriString = uri.toString() val uriString = uri.toString()
@ -163,9 +212,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
lineChannelResult.onSuccess { line -> lineChannelResult.onSuccess { line ->
if (line is LinkLine) { if (line is LinkLine) {
// Mark visited links here as we have a access to the history. // Mark visited links here as we have a access to the history.
val fullUrl = val fullUrl = resolveLinkUri(line.url, uriString).toString()
if (Uri.parse(line.url).isAbsolute) line.url
else joinUrls(uriString, line.url).toString()
if (History.contains(fullUrl)) if (History.contains(fullUrl))
line.visited = true line.visited = true
} }
@ -195,15 +242,26 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
// We record the history entry here: it's nice because we have the main title available // We record the history entry here: it's nice because we have the main title available
// and we're already in a coroutine for database access. // and we're already in a coroutine for database access.
History.record(uri.toString(), mainTitle) History.record(uriString, mainTitle)
event.postValue(SuccessEvent(uri.toString())) event.postValue(SuccessEvent(uriString))
state.postValue(State.IDLE) 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) { 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"
@ -235,6 +293,33 @@ 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", "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") }
)
}
else -> {
// TODO use SAF
signalError("MIME type unsupported yet: ${mimeType.main} (\"${mimeType.short}\")")
}
}
} }
companion object { companion object {

View file

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.BufferedInputStream import java.io.BufferedInputStream
@ -19,6 +20,12 @@ import javax.net.ssl.*
class Request(private val uri: Uri, private val keyManager: KeyManager? = null) { class Request(private val uri: Uri, private val keyManager: KeyManager? = null) {
private val port get() = if (uri.port > 0) uri.port else 1965 private val port get() = if (uri.port > 0) uri.port else 1965
/**
* Open and return the TLS socket with the server.
*
* If the server certificate present is fine according to our TOFU settings, the app can
* continue by calling `proceed` which will retrieve the data.
*/
fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket { fun connect(protocol: String, connectionTimeout: Int, readTimeout: Int): SSLSocket {
Log.d( Log.d(
TAG, TAG,
@ -34,18 +41,18 @@ class Request(private val uri: Uri, private val keyManager: KeyManager? = null)
return socket return socket
} }
/** Return a byte array channel carrying the data chunks sent by the server. */
fun proceed(socket: SSLSocket, scope: CoroutineScope): Channel<ByteArray> { fun proceed(socket: SSLSocket, scope: CoroutineScope): Channel<ByteArray> {
Log.d(TAG, "proceed") Log.d(TAG, "proceed")
socket.outputStream.write("$uri\r\n".toByteArray()) socket.outputStream.write("$uri\r\n".toByteArray())
val channel = Channel<ByteArray>() val channel = Channel<ByteArray>()
scope.launch { scope.launch(Dispatchers.IO) {
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
var numRead: Int var numRead: Int
socket.inputStream.use { socket_input_stream -> socket.inputStream.use { socket_input_stream ->
BufferedInputStream(socket_input_stream).use { bis -> BufferedInputStream(socket_input_stream).use { bis ->
try { try {
@Suppress("BlockingMethodInNonBlockingContext") // what u gonna do
while ((bis.read(buffer).also { numRead = it }) >= 0) { while ((bis.read(buffer).also { numRead = it }) >= 0) {
val received = buffer.sliceArray(0 until numRead) val received = buffer.sliceArray(0 until numRead)
channel.send(received) channel.send(received)
@ -63,6 +70,13 @@ class Request(private val uri: Uri, private val keyManager: KeyManager? = null)
return channel return channel
} }
/**
* Dummy KeyManager to be used when an client cert is to be used during the connection.
*
* This simply retrieves both the public cert and private key from the Android key store
* and implement dummy methods to return only this key pair. Some methods are left unimplemented
* because they should never be executed in the context we use the key manager in.
*/
class KeyManager( class KeyManager(
private val alias: String, private val alias: String,
private val cert: X509Certificate, private val cert: X509Certificate,

View file

@ -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<ByteArray>,
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"

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

@ -2,6 +2,26 @@ package dev.lowrespalmtree.comet.utils
import android.net.Uri import android.net.Uri
/**
* Resolve the URI of a link found on a page.
*
* Links can take various forms: absolute links to a page on a capsule, relative links on the same
* capsule, but also fancy scheme-less absolute URLs (i.e. starting with "//") for cross-protocol
* linking. This function returns the resolved URI from any type of link, opt. using current URL.
*/
fun resolveLinkUri(url: String, base: String?): Uri {
var uri = Uri.parse(url)
if (!uri.isAbsolute) {
uri =
if (url.startsWith("//")) uri.buildUpon().scheme("gemini").build()
else if (!base.isNullOrEmpty()) joinUrls(base, url)
else toGeminiUri(uri)
} else if (uri.scheme == "gemini" && uri.path.isNullOrEmpty()) {
uri = uri.buildUpon().path("/").build()
}
return uri
}
/** /**
* Transform a relative URI to an absolute Gemini URI * Transform a relative URI to an absolute Gemini URI
* *

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>

View file

@ -0,0 +1,38 @@
package dev.lowrespalmtree.comet
import org.junit.Assert.*
import org.junit.Test
class MimeTypeTests {
@Test
fun from() {
assertNull(MimeType.from(""))
assertNull(MimeType.from("dumb"))
assertNull(MimeType.from("dumb;dumber"))
assertNull(MimeType.from("123456"))
MimeType.from("a/b")?.run {
assertEquals("a", main)
assertEquals("b", sub)
assertEquals(mapOf<String, String>(), params)
} ?: fail()
MimeType.from("text/gemini")?.run {
assertEquals("text", main)
assertEquals("gemini", sub)
assertEquals(mapOf<String, String>(), params)
} ?: fail()
MimeType.from("text/gemini;lang=en")?.run {
assertEquals("text", main)
assertEquals("gemini", sub)
assertEquals(mapOf("lang" to "en"), params)
} ?: fail()
MimeType.from("text/gemini ;lang=en")?.run {
assertEquals("text", main)
assertEquals("gemini", sub)
assertEquals(mapOf("lang" to "en"), params)
} ?: fail()
}
}