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:
1 parent
6ab4f0e557
commit
eb191d945a
10 files changed
+826
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user