feat(chapters): a chapter can be loaded from a file

Phase 1 of docs/chapter-uploads.md. The picker and the decode, with one file
going into the add-chapter form that already works -- so the platform seam is
exercised on its own, before anything knows about several chapters at once.

Three actuals rather than FileKit. This is the app's only dialog, the calls
are a dozen lines each, and the project already keeps four platform seams of
this shape; the library's Compose-version coupling buys nothing here, and the
ios target cannot be compiled on a linux host either way.

ChapterText.decode is the part that matters. The web app does
`file.data.toString()`, where a .docx becomes a chapter of zip-header garbage
the uploader can go back and delete; here the next thing that happens to the
text is that a quorum signs it. So: strict UTF-8, because a lenient decode
hides U+FFFD inside something permanent; a NUL sniff before decoding, because
mostly-ASCII binary decodes cleanly; the byte-order mark stripped and CRLF
normalised, because both change the bytes without changing a word and the
same prose from two machines has to give the same chunk ids.

The pickers stop one byte past the cap rather than at it, so an over-size
file reads as over-size instead of as a chapter with its ending missing.
Android registers both open contracts so asking for one file does not offer a
picker that returns six. The dialog title is a parameter read from the
catalogue, since stringResource is composable and a FileDialog is not.

ChapterNaming came out of the two answers the web app gives on its two routes
-- the filename on the upload, the first chunk on the paste form -- both of
which are right some of the time. The filename minus extension and ordinal
prefix, falling through to a heading and then the first line, because `01.md`
is not a chapter title and `03 - The Wreck.md` is.

FilePicker.ios.kt is unverified: the ios targets are declared only on macos,
so it is not compiled here. Written to the shape of the eight .ios.kt actuals
beside it and says so in its KDoc. A stub returning no files would read as a
working picker that a reader's chapters vanish into.

22 new tests, all pure; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 18:29:20 +02:00
parent 6ab4f0e557
commit eb191d945a
10 changed files with 826 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
package press.mantra.compose.files
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import co.touchlab.kermit.Logger
import press.mantra.compose.text.ChapterText
import java.io.InputStream
/**
* The Storage Access Framework, which needs no manifest permission at all.
*
* `OpenDocument` and `OpenMultipleDocuments` are a handoff to the system
* picker: the app never sees the file system, and the grant it comes back with
* covers exactly the documents the reader chose. `READ_EXTERNAL_STORAGE` and
* its API 33 replacements are for apps that browse storage themselves, which
* this does not.
*
* **Both contracts are registered, not just the multiple one.** Registering
* only `OpenMultipleDocuments` and ignoring the flag would work, and would mean
* that the one-file case -- loading a chapter into the add form -- offers a
* picker that lets the reader select six. Asking for one thing and accepting
* six is how a form ends up quietly discarding five of them.
*
* The mime types are deliberately loose. Markdown has no registered type:
* providers report `.md` as `text/markdown`, `text/plain`, `application/octet-stream`
* or nothing at all depending on the provider and the Android version, so a
* narrow filter hides the very files this is for. Filtering on `text/*` plus
* `*/*` and letting `ChapterText.decode` be the judge is both more permissive
* at the picker and stricter about what becomes a chapter.
*
* `title` is unused. `Intent.EXTRA_TITLE` is honoured by `CreateDocument`, where
* it is the suggested filename, and ignored by the open contracts -- the system
* picker titles itself. Nothing is lost by taking the parameter; something would
* be lost by making desktop's title a literal to avoid it.
*/
@Composable
actual fun rememberFilePicker(
title: String,
onPicked: (List<PickedFile>) -> Unit,
): FilePicker {
val context = LocalContext.current
val resolver = remember(context) { context.contentResolver }
val single = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
onPicked(listOfNotNull(uri?.let { resolver.readPickedFile(it) }))
}
val multiple = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
onPicked(uris.mapNotNull { resolver.readPickedFile(it) })
}
return remember(single, multiple) {
FilePicker { wantsMultiple ->
if (wantsMultiple) multiple.launch(MIME_TYPES) else single.launch(MIME_TYPES)
}
}
}
private val MIME_TYPES = arrayOf("text/*", "*/*")
/**
* Reads a picked document, stopping one byte past the cap.
*
* One byte *past*, so an over-size file comes back over-size rather than
* truncated to exactly the limit -- a truncated chapter would decode cleanly
* and be proposed with its ending missing.
*
* A failure here is a document that went away between being picked and being
* read, or a provider that threw; logged and dropped, because the alternative
* is failing the whole selection over one file.
*/
private fun ContentResolver.readPickedFile(uri: Uri): PickedFile? =
runCatching {
val bytes = openInputStream(uri)?.use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) }
?: return null
PickedFile(name = displayName(uri) ?: uri.lastPathSegment.orEmpty(), bytes = bytes)
}.onFailure { error ->
Logger.withTag(TAG).e("Could not read the picked document $uri", error)
}.getOrNull()
private fun ContentResolver.displayName(uri: Uri): String? =
runCatching {
query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
}.getOrNull()
private fun InputStream.readAtMost(limit: Int): ByteArray {
val buffer = ByteArray(limit)
var filled = 0
while (filled < limit) {
val read = read(buffer, filled, limit - filled)
if (read < 0) break
filled += read
}
return buffer.copyOf(filled)
}
private const val TAG = "FilePicker"

View File

@@ -491,4 +491,8 @@
<string name="you_already_have_a_chat_with_s">You already have a chat with %1$s.</string>
<string name="checking_whether_s_can_receive_messages_here">Checking whether %1$s can receive messages here.</string>
<string name="s_hasn_t_set_up_messaging_on_mantra_yet">%1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra.</string>
<string name="load_from_file">Load from file</string>
<string name="choose_chapter_files">Choose chapter files</string>
<string name="that_file_isn_t_text">That file isn't text. A chapter is markdown or plain text.</string>
<string name="that_file_is_too_large">That file is too large to be a chapter.</string>
</resources>

View File

@@ -0,0 +1,72 @@
package press.mantra.compose.files
import androidx.compose.runtime.Composable
/**
* A file the reader picked, as its name and its bytes.
*
* Bytes rather than a path or an open stream, and that is a decision rather
* than laziness. A path is meaningless on iOS the moment the picker's security
* scope closes, so a path would be a handle that works on two platforms and
* expires on the third. A stream would push the size cap and the decode into
* whichever coroutine happened to read it. Chapters are tens of kilobytes;
* `ChapterText.MAX_FILE_BYTES` bounds the read and the whole file fits in hand.
*
* [name] is for naming the chapter, not for deciding what the file is -- that
* is `ChapterText.decode`'s job, by looking at the bytes. On android the name
* comes from the document provider and can be absent or a lie; it is a default
* a person edits, never a check.
*/
data class PickedFile(
val name: String,
val bytes: ByteArray,
) {
// ByteArray makes the generated equals/hashCode identity-based, which is
// wrong for a data class that will sit in a list in UI state and be
// compared across recompositions.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is PickedFile) return false
return name == other.name && bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int = 31 * name.hashCode() + bytes.contentHashCode()
}
/** Opens the platform's file chooser. */
fun interface FilePicker {
fun launch(multiple: Boolean)
}
/**
* The platform's file chooser, wired to hand [onPicked] what came back.
*
* `expect` rather than a dependency. The obvious alternative is FileKit, which
* covers all three targets from common code -- but this is the only thing in
* the app that needs a dialog, the three calls below are a dozen lines each,
* and the project already keeps four platform seams of exactly this shape
* (`PlatformContext`, `PlatformDatabaseBuilder`, `platformReducedMotion`,
* `platformThemeContrast`). A dependency whose Compose version has to track
* ours is a poor trade for that.
*
* **Nothing is read beyond `ChapterText.MAX_FILE_BYTES`.** Each actual stops
* there, so picking a video by mistake costs a bounded read rather than the
* whole file, and the over-size file still arrives -- with more bytes than the
* cap allows -- so that `decode` can say `TooLarge` instead of the picker
* silently dropping it.
*
* Cancelling hands back an empty list, which is how a caller tells "no files"
* from "files that would not decode".
*
* [title] is the dialog's own title, read from the string catalogue by the
* caller because `stringResource` is composable and the dialog is not. Only
* desktop uses it: android's Storage Access Framework and iOS's document
* browser each title themselves and offer no way to say otherwise. Taken on all
* three anyway, so that the one platform which shows text to the reader gets it
* from the catalogue like every other string in the app.
*/
@Composable
expect fun rememberFilePicker(
title: String,
onPicked: (List<PickedFile>) -> Unit,
): FilePicker

View File

@@ -0,0 +1,66 @@
package press.mantra.compose.text
/**
* What to call a chapter that arrived as a file.
*
* The web app this mirrors has two answers and uses a different one per route:
* the upload names a chapter `file.name` and the paste form names it
* `chunks[0].trim()`. Both are right some of the time. `chapter-01.md` is a
* filename, not a title, and a chapter whose first paragraph is prose has a
* better name in its own text; a chapter whose file is called
* `03 - The Wreck of the Hesperus.md` does not.
*
* So: the filename first, because somebody chose it, and the text only when the
* filename says nothing a reader would want -- and a heading in the text before
* a paragraph of it, because a heading is a title and a paragraph is the
* beginning of the work.
*
* Whatever this returns is a **default in an editable field**, never the final
* answer. A quorum is asked to sign the name along with the chapter, so the
* person uploading has to be able to fix it first.
*/
object ChapterNaming {
/** Past this, a name is the beginning of a paragraph rather than a title. */
private const val MAX_DERIVED_LENGTH = 80
private val LEADING_ORDINAL = Regex("^[\\s\\-_.0-9]+")
fun forFile(fileName: String, markdown: String): String =
fromFileName(fileName) ?: fromMarkdown(markdown) ?: ""
/**
* The filename with its extension and its ordinal prefix removed.
*
* `03 - The Wreck.md` is `The Wreck`; `chapter-01.md` is `chapter`, which is
* a poor title but an honest reflection of a poor filename and is the one
* the person will edit. What is dropped entirely is a name that is *only*
* an ordinal -- `01.md`, `1.txt` -- because "01" as a chapter title is worse
* than falling through to the text.
*/
private fun fromFileName(fileName: String): String? =
fileName.substringBeforeLast('.', fileName)
.replace('_', ' ')
.replace(LEADING_ORDINAL, "")
.trim()
.takeIf { it.isNotEmpty() }
/**
* The first heading in the text, or failing that its first line.
*
* Reads the markdown rather than [Markdown]'s split, because this runs while
* a file is being looked at rather than while a chapter is being proposed,
* and a heading's `#` has to be stripped either way.
*/
private fun fromMarkdown(markdown: String): String? {
val lines = markdown.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }
val heading = lines.firstOrNull { it.startsWith("#") }
?.trimStart('#')
?.trim()
?.takeIf { it.isNotEmpty() }
if (heading != null) return heading.take(MAX_DERIVED_LENGTH)
return lines.firstOrNull()?.take(MAX_DERIVED_LENGTH)
}
}

View File

@@ -0,0 +1,97 @@
package press.mantra.compose.text
/**
* Turning the bytes of a picked file into the markdown a chapter is made of.
*
* Deliberately not `bytes.decodeToString()`, which is what the web app this
* mirrors does (`file.data.toString()` in `server/router/versions/index.js`).
* There, a `.docx` dropped into the form becomes a chapter of zip-header
* garbage that the uploader can go back and delete. Here the next thing that
* happens to this text is that a quorum is asked to sign it, so what cannot be
* read as prose has to be refused at the door rather than corrected afterwards.
*
* All four rules below exist because of that one difference. None of them is
* about being tidy; each is about something that would otherwise be permanent.
*/
object ChapterText {
/**
* The most bytes a picked file may be.
*
* A crash guard, not the real limit -- what a chapter may actually carry is
* bounded by what one signing proposal can put on the wire, which is far
* tighter. This is here so that picking a video by mistake fails
* on a comparison rather than on a megabyte-counting read, and so the
* platform pickers have a number to stop reading at.
*/
const val MAX_FILE_BYTES: Int = 1 * 1024 * 1024
/** How far into a file to look for the NUL that says it is not text. */
private const val SNIFF_BYTES: Int = 8 * 1024
sealed interface Result {
/** Decoded, BOM-stripped, newline-normalised markdown. */
data class Text(val markdown: String) : Result
/** Not decodable as UTF-8, or carrying a NUL. */
data object NotText : Result
/** Over [MAX_FILE_BYTES]. */
data object TooLarge : Result
}
fun decode(bytes: ByteArray): Result {
if (bytes.size > MAX_FILE_BYTES) return Result.TooLarge
// Sniffed before decoding, not after. A file of UTF-16 text decodes as
// invalid UTF-8 and would be refused anyway, but a file of mostly-ASCII
// binary -- a zip's local header, a PDF's preamble -- can decode
// cleanly and only reads as prose if nobody looks for the NUL.
if (hasNul(bytes)) return Result.NotText
// Strict, so an invalid sequence is refused rather than replaced. A
// lenient decode substitutes U+FFFD, which is invisible in a text
// field, is signed into the chapter's `original_text` tag, and is then
// part of the group's record of the work for good.
val decoded = runCatching {
bytes.decodeToString(throwOnInvalidSequence = true)
}.getOrElse { return Result.NotText }
return Result.Text(normalise(decoded))
}
/**
* The form of a chapter's markdown that is safe to hash.
*
* Two rewrites, both of which change the text's bytes without changing a
* word of it, which is exactly why they belong here rather than at a call
* site:
*
* **The byte-order mark goes.** A leading `U+FEFF` is invisible in the form
* and in the chapter, and it is part of the first chunk's text -- so the
* same opening paragraph saved by two editors is two different chunk ids,
* and a translation of one does not match the other.
*
* **CRLF becomes LF.** [Markdown]'s paragraph rule already tolerates `\r\n`,
* so splitting is unaffected; what is affected is the `original_text` tag,
* which would carry the carriage returns. The same chapter uploaded from
* Windows and from Linux would be two different events over identical
* prose. A lone `\r` -- classic Mac line endings, and what some converters
* still emit -- is normalised too, for the same reason.
*
* Public because the duplicate check on an upload compares chapters by
* their text, and it has to compare the form that will be signed.
*/
fun normalise(text: String): String =
text.removePrefix("")
.replace("\r\n", "\n")
.replace("\r", "\n")
private fun hasNul(bytes: ByteArray): Boolean {
val end = minOf(bytes.size, SNIFF_BYTES)
for (index in 0 until end) {
if (bytes[index] == 0.toByte()) return true
}
return false
}
}

View File

@@ -6,12 +6,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -19,10 +22,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
@@ -36,6 +41,9 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.files.rememberFilePicker
import press.mantra.compose.text.ChapterNaming
import press.mantra.compose.text.ChapterText
import press.mantra.compose.text.Markdown
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
@@ -55,8 +63,13 @@ import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_
import mantra.composeapp.generated.resources.propose_chapter
import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter
import mantra.composeapp.generated.resources.add_chapter_to
import mantra.composeapp.generated.resources.choose_chapter_files
import mantra.composeapp.generated.resources.load_from_file
import mantra.composeapp.generated.resources.that_file_is_too_large
import mantra.composeapp.generated.resources.that_file_isn_t_text
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
@@ -107,6 +120,42 @@ fun AddChapterScreen(
val nameFieldState = rememberTextFieldState()
val originalTextFieldState = rememberTextFieldState()
// Read above the handler, not inside it: `rememberNotifier` and
// `stringResource` are composable and a click lambda is not.
val notify = rememberNotifier(rememberCoroutineScope())
val notText = stringResource(Res.string.that_file_isn_t_text)
val tooLarge = stringResource(Res.string.that_file_is_too_large)
// One file, into the form that already works. Picking several
// chapters at once is the upload screen's job; here the point is
// to stop a chapter having to be pasted through a keyboard.
val picker = rememberFilePicker(
title = stringResource(Res.string.choose_chapter_files)
) { files ->
val file = files.firstOrNull() ?: return@rememberFilePicker
when (val decoded = ChapterText.decode(file.bytes)) {
is ChapterText.Result.Text -> {
originalTextFieldState.setTextAndPlaceCursorAtEnd(decoded.markdown)
// The filename only fills a name nobody has typed.
// Overwriting one that was typed would make loading a
// file a destructive act on a field beside it.
if (nameFieldState.text.isBlank()) {
nameFieldState.setTextAndPlaceCursorAtEnd(
ChapterNaming.forFile(file.name, decoded.markdown)
)
}
// No snackbar on success. The text arriving in the
// field below is the feedback, and a message over
// the top of it would say less than the thing
// itself.
}
ChapterText.Result.NotText -> notify(notText)
ChapterText.Result.TooLarge -> notify(tooLarge)
}
}
// Live counts / paragraph (chunk) preview from the markdown text.
val originalText = originalTextFieldState.text.toString()
val wordCount = Markdown.wordCount(originalText)
@@ -221,6 +270,15 @@ fun AddChapterScreen(
placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) },
)
TextButton(onClick = { picker.launch(multiple = false) }) {
Icon(
Icons.Default.UploadFile,
contentDescription = "Load the chapter from a file"
)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(stringResource(Res.string.load_from_file))
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth().weight(1f),
state = originalTextFieldState,

View File

@@ -0,0 +1,68 @@
package press.mantra.compose.text
import kotlin.test.Test
import kotlin.test.assertEquals
class ChapterNamingTest {
private val body = "The first paragraph.\n\nThe second.\n"
@Test
fun `a filename somebody chose is the name`() {
assertEquals(
"The Wreck of the Hesperus",
ChapterNaming.forFile("The Wreck of the Hesperus.md", body),
)
}
@Test
fun `an ordinal prefix is dropped`() {
assertEquals("The Wreck", ChapterNaming.forFile("03 - The Wreck.md", body))
assertEquals("The Wreck", ChapterNaming.forFile("1. The Wreck.markdown", body))
}
@Test
fun `underscores read as spaces`() {
assertEquals("the wreck", ChapterNaming.forFile("the_wreck.txt", body))
}
@Test
fun `a filename that is only an ordinal falls through to the text`() {
assertEquals("The first paragraph.", ChapterNaming.forFile("01.md", body))
}
@Test
fun `a heading in the text beats the first paragraph`() {
assertEquals(
"Chapter one",
ChapterNaming.forFile("1.md", "# Chapter one\n\nThe first paragraph.\n"),
)
}
@Test
fun `a heading glued to its paragraph is still found`() {
// The shape mantra.press's TeX converter emits: a heading and the
// paragraph under it separated by a single newline.
assertEquals(
"Chapter one",
ChapterNaming.forFile("1.md", "# Chapter one\nThe first paragraph.\n"),
)
}
@Test
fun `a derived name is cut to a title's length`() {
val long = "w ".repeat(200)
assertEquals(80, ChapterNaming.forFile("1.md", long).length)
}
@Test
fun `nothing to go on gives an empty name for the form to ask about`() {
assertEquals("", ChapterNaming.forFile("01.md", " \n\n \n"))
}
@Test
fun `a file with no extension keeps its whole name`() {
assertEquals("CHAPTER ONE", ChapterNaming.forFile("CHAPTER ONE", body))
}
}

View File

@@ -0,0 +1,126 @@
package press.mantra.compose.text
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* The four rules [ChapterText.decode] exists for, and the one property that
* makes them worth having: the same prose from two machines has to come out as
* the same bytes, because those bytes become a chunk id a quorum signs.
*/
class ChapterTextTest {
private fun decoded(bytes: ByteArray): String {
val result = ChapterText.decode(bytes)
assertTrue(result is ChapterText.Result.Text, "expected text, got $result")
return result.markdown
}
@Test
fun `plain utf8 markdown decodes unchanged`() {
val markdown = "# Chapter one\n\nThe first paragraph.\n"
assertEquals(markdown, decoded(markdown.encodeToByteArray()))
}
@Test
fun `multi byte characters survive`() {
// Setswana and a few of the diacritics a translation will actually carry.
val markdown = "Ke a leboga.\n\nŠŌÜ — ôô.\n"
assertEquals(markdown, decoded(markdown.encodeToByteArray()))
}
@Test
fun `an invalid utf8 sequence is refused rather than replaced`() {
// 0xFF is not a legal lead byte anywhere in UTF-8.
val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42)
assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes))
}
@Test
fun `a replacement character is never introduced`() {
val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42)
val lenient = bytes.decodeToString()
// The point of the rule: a lenient decode succeeds and hides the damage
// inside text that would then be signed.
assertTrue(lenient.contains('<27>'))
assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes))
}
@Test
fun `a byte order mark is stripped`() {
val withBom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) +
"First paragraph.".encodeToByteArray()
assertEquals("First paragraph.", decoded(withBom))
}
@Test
fun `the same chapter from windows and from linux decodes identically`() {
val crlf = "# Chapter one\r\n\r\nThe first paragraph.\r\nIts second line.\r\n"
val lf = "# Chapter one\n\nThe first paragraph.\nIts second line.\n"
assertEquals(decoded(lf.encodeToByteArray()), decoded(crlf.encodeToByteArray()))
// And the thing the rule is actually protecting: without it the two are
// different bytes over identical prose, so different chunk ids.
assertNotEquals(crlf, lf)
}
@Test
fun `a lone carriage return is normalised too`() {
assertEquals("one\ntwo", decoded("one\rtwo".encodeToByteArray()))
}
@Test
fun `splitting is unaffected by which newlines arrived`() {
val crlf = decoded("One.\r\n\r\nTwo.\r\n\r\nThree.".encodeToByteArray())
assertEquals(listOf("One.", "Two.", "Three."), Markdown.splitParagraphs(crlf))
}
@Test
fun `a nul byte means the file is not text`() {
val zipHeader = byteArrayOf(0x50, 0x4B, 0x03, 0x04, 0x00, 0x00) +
"docx-ish".encodeToByteArray()
assertEquals(ChapterText.Result.NotText, ChapterText.decode(zipHeader))
}
@Test
fun `a nul past the sniff window is not looked for`() {
// Documents the limit rather than claiming more than it does: the sniff
// reads 8 KiB, and a file whose first 8 KiB are prose reads as prose.
val bytes = "a".repeat(9 * 1024).encodeToByteArray() + byteArrayOf(0x00)
assertTrue(ChapterText.decode(bytes) is ChapterText.Result.Text)
}
@Test
fun `utf16 is refused, because it is not supported rather than because it is binary`() {
// FF FE is a UTF-16LE byte order mark and an illegal UTF-8 lead byte, so
// this lands on NotText either way. Asserted so that a future lenient
// decode cannot quietly turn it into a chapter of interleaved NULs.
val utf16 = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x41, 0x00, 0x42, 0x00)
assertEquals(ChapterText.Result.NotText, ChapterText.decode(utf16))
}
@Test
fun `a file at the cap decodes and one byte over does not`() {
val atCap = ByteArray(ChapterText.MAX_FILE_BYTES) { 'a'.code.toByte() }
val overCap = ByteArray(ChapterText.MAX_FILE_BYTES + 1) { 'a'.code.toByte() }
assertTrue(ChapterText.decode(atCap) is ChapterText.Result.Text)
assertEquals(ChapterText.Result.TooLarge, ChapterText.decode(overCap))
}
@Test
fun `an empty file is text, not a failure`() {
// Whether an empty chapter may be proposed is the form's decision; decode
// only says what the bytes were.
assertEquals("", decoded(ByteArray(0)))
}
}

View File

@@ -0,0 +1,135 @@
package press.mantra.compose.files
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import co.touchlab.kermit.Logger
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.Foundation.NSData
import platform.Foundation.NSURL
import platform.Foundation.dataWithContentsOfURL
import platform.UIKit.UIApplication
import platform.UIKit.UIDocumentPickerDelegateProtocol
import platform.UIKit.UIDocumentPickerViewController
import platform.UIKit.UIViewController
import platform.UniformTypeIdentifiers.UTTypeData
import platform.UniformTypeIdentifiers.UTTypePlainText
import platform.darwin.NSObject
import press.mantra.compose.text.ChapterText
/**
* `UIDocumentPickerViewController`, presented over whatever is on screen.
*
* **Unverified on the host this was written on.** The two ios targets are
* declared only on macos -- see the gate in `composeApp/build.gradle.kts` and
* the reason in `docs/jvm-target.md` -- so this file is not compiled on linux
* and has never been run. It is written to the same shape as the other eight
* `.ios.kt` actuals in this source set and should be read on a mac before it is
* trusted. The alternative, a stub that hands back no files, would be worse:
* it reads as a working picker that the reader's chapters vanish into.
*
* `forOpeningContentTypes` rather than the deprecated `initWithDocumentTypes`,
* which takes UTI strings and was removed from the iOS 14 API surface in all
* but name.
*
* **The types are `UTTypePlainText` and `UTTypeData`**, for the reason android's
* are loose: markdown has no registered UTI, so `.md` resolves to
* `public.plain-text` on some devices and to `public.data` on others depending
* on what else is installed. Offering both means the files this exists for are
* selectable; `ChapterText.decode` is what decides whether they are chapters.
*
* **The delegate is held by the composition, not by the view controller.** UIKit
* holds its delegate weakly, so a delegate created inline is collected before
* the reader finishes choosing and the callback never fires -- the classic
* version of this bug. `remember` is what keeps it alive, and is why it is
* built here rather than inside `launch`.
*/
@OptIn(ExperimentalForeignApi::class)
@Composable
actual fun rememberFilePicker(
title: String,
onPicked: (List<PickedFile>) -> Unit,
): FilePicker {
val delegate = remember(onPicked) { PickerDelegate(onPicked) }
return remember(delegate) {
FilePicker { multiple ->
val picker = UIDocumentPickerViewController(
forOpeningContentTypes = listOf(UTTypePlainText, UTTypeData),
asCopy = true,
)
picker.allowsMultipleSelection = multiple
picker.delegate = delegate
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
if (presenter == null) {
Logger.withTag(TAG).e("No root view controller to present the document picker from")
onPicked(emptyList())
} else {
presenter.topmost().presentViewController(picker, animated = true, completion = null)
}
}
}
}
/**
* `asCopy = true` above puts the chosen files in the app's own temporary
* directory, which is what makes reading them afterwards legal without
* `startAccessingSecurityScopedResource`. It is also why [PickedFile] carries
* bytes rather than a url: the copy is the app's to read now and the system's
* to delete whenever it likes.
*/
@OptIn(ExperimentalForeignApi::class)
private class PickerDelegate(
private val onPicked: (List<PickedFile>) -> Unit,
) : NSObject(), UIDocumentPickerDelegateProtocol {
override fun documentPicker(
controller: UIDocumentPickerViewController,
didPickDocumentsAtURLs: List<*>,
) {
onPicked(didPickDocumentsAtURLs.filterIsInstance<NSURL>().mapNotNull(::readPickedFile))
}
override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) {
onPicked(emptyList())
}
}
/** As on the other two platforms: one byte past the cap, so over-size reads as over-size. */
@OptIn(ExperimentalForeignApi::class)
private fun readPickedFile(url: NSURL): PickedFile? =
runCatching {
val data = NSData.dataWithContentsOfURL(url) ?: return null
PickedFile(
name = url.lastPathComponent ?: "",
bytes = data.toByteArray(limit = ChapterText.MAX_FILE_BYTES + 1),
)
}.onFailure { error ->
Logger.withTag(TAG).e("Could not read the picked document $url", error)
}.getOrNull()
@OptIn(ExperimentalForeignApi::class)
private fun NSData.toByteArray(limit: Int): ByteArray {
val size = minOf(length.toInt(), limit)
if (size == 0) return ByteArray(0)
val out = ByteArray(size)
out.usePinned { pinned ->
platform.posix.memcpy(pinned.addressOf(0), bytes, size.toULong())
}
return out
}
/**
* The controller actually on screen.
*
* Presenting from the root controller while a sheet is already up throws, and
* this app reaches the add-chapter form through navigation that may have put one
* there. Walking `presentedViewController` is the documented way to find the
* one that can present.
*/
private fun UIViewController.topmost(): UIViewController =
presentedViewController?.topmost() ?: this
private const val TAG = "FilePicker"

View File

@@ -0,0 +1,91 @@
package press.mantra.compose.files
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import press.mantra.compose.text.ChapterText
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import java.io.InputStream
/**
* AWT's `FileDialog`, which is the native chooser on all three desktops.
*
* `JFileChooser` was the alternative and is worse here: it is Swing's own
* dialog, so it looks like Swing rather than like the host, and on macos it is
* notably not the file panel every other application shows. `FileDialog` maps
* to the platform's own in each case.
*
* **It blocks the calling thread**, which on desktop Compose is the UI thread,
* so showing it from a click handler freezes the window until it closes -- and
* reading the files afterwards would freeze it again. Both go to
* [Dispatchers.IO] and the result comes back through [onPicked] on the main
* dispatcher, which is the same shape the android launcher has by construction.
*
* The scope is the composition's. A dialog whose composable has left is a
* dialog whose result has nowhere to go, and cancelling the read is the right
* behaviour rather than a leak to avoid.
*/
@Composable
actual fun rememberFilePicker(
title: String,
onPicked: (List<PickedFile>) -> Unit,
): FilePicker {
val scope = rememberCoroutineScope()
return remember(scope, title, onPicked) {
FilePicker { multiple ->
scope.launch {
val picked = withContext(Dispatchers.IO) { chooseFiles(title, multiple) }
onPicked(picked)
}
}
}
}
private fun chooseFiles(title: String, multiple: Boolean): List<PickedFile> {
// A null parent is allowed and gives an unowned dialog. It is what a Compose
// desktop window gets us without reaching for the AWT window behind it,
// which `ComposeWindow` exposes but only from a `FrameWindowScope` that the
// app's navigation graph does not thread through.
val dialog = object : FileDialog(null as Frame?, title, LOAD) {}
dialog.isMultipleMode = multiple
dialog.isVisible = true
val chosen = if (multiple) {
dialog.files?.toList().orEmpty()
} else {
listOfNotNull(dialog.directory?.let { dir -> dialog.file?.let { File(dir, it) } })
}
return chosen.mapNotNull(::readPickedFile)
}
/** As on android: one byte past the cap, so an over-size file reads as over-size. */
private fun readPickedFile(file: File): PickedFile? =
runCatching {
PickedFile(
name = file.name,
bytes = file.inputStream().use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) },
)
}.onFailure { error ->
Logger.withTag(TAG).e("Could not read the picked file ${file.path}", error)
}.getOrNull()
private fun InputStream.readAtMost(limit: Int): ByteArray {
val buffer = ByteArray(limit)
var filled = 0
while (filled < limit) {
val read = read(buffer, filled, limit - filled)
if (read < 0) break
filled += read
}
return buffer.copyOf(filled)
}
private const val TAG = "FilePicker"