diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7eaf0ee..d3b079f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -125,17 +125,27 @@ Capture without opening the app first: Share → Inkwell from anywhere, and the selection toolbar in any text field. - text/plain ONLY, and image/* deliberately absent. Nothing in this - app can create an attachment — the core has `delete_attachment` and - no counterpart, and the FFI exposes neither. Claiming images in the - share sheet would put this app in front of people for a job it - cannot do and fail after they had chosen it. + Text, and images one at a time or several at once. A shared image + becomes a note carrying it as an attachment, stored on the phone + and uploaded on the next sync that reaches a server (#5169). + + image/* rather than */*: a photo or a screenshot is what people + share into a notes app. Claiming every type would put Inkwell in the + share sheet for APKs, contacts and calendar entries, where it would + be noise. Other files attach from inside the editor, whose picker + takes any type. --> + + + + + + + + + + + diff --git a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt index 78c60ab..2cbdae9 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt @@ -2,6 +2,7 @@ package com.fabledsword.inkwell import android.Manifest import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity @@ -11,6 +12,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -21,14 +23,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.core.content.IntentCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.fabledsword.inkwell.core.Inkwell +import com.fabledsword.inkwell.ui.AttachmentFiles import com.fabledsword.inkwell.ui.BoardScreen import com.fabledsword.inkwell.ui.BoardSync import com.fabledsword.inkwell.ui.BoardUpdate import com.fabledsword.inkwell.ui.BoardViewModel import com.fabledsword.inkwell.ui.ForegroundTransitions import com.fabledsword.inkwell.ui.InkwellTheme +import com.fabledsword.inkwell.ui.LocalAttachmentFiles import com.fabledsword.inkwell.ui.NoteEditorScreen import com.fabledsword.inkwell.ui.StoreUnavailableScreen import com.fabledsword.inkwell.ui.SyncScreen @@ -54,7 +59,7 @@ class MainActivity : ComponentActivity() { private val requestedNote = mutableStateOf(null) /** - * Text shared into the app from elsewhere, waiting to become a note. + * Text or files shared into the app from elsewhere, waiting to become a note. * * Same shape and same reason as [requestedNote]: a share that arrives while * the app is already running lands in [onNewIntent], long after the @@ -62,7 +67,7 @@ class MainActivity : ComponentActivity() { * way in. The activity is `singleTop` in the manifest precisely so that this * path exists for an intent another app built. */ - private val sharedText = mutableStateOf(null) + private val shared = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -70,7 +75,7 @@ class MainActivity : ComponentActivity() { val app = application as InkwellApplication requestedNote.value = takeRequestedNote(intent) - sharedText.value = takeSharedText(intent) + shared.value = takeShared(intent) setContent { InkwellTheme { @@ -81,7 +86,12 @@ class MainActivity : ComponentActivity() { // than render an empty board that looks like data loss. StoreUnavailableScreen(reason = app.openFailure) } else { - App(core, requestedNote, sharedText) + // Application context inside, so holding it for the + // composition's life cannot leak this activity. + val files = remember(core) { AttachmentFiles(this, core) } + CompositionLocalProvider(LocalAttachmentFiles provides files) { + App(core, files, requestedNote, shared) + } } } } @@ -91,7 +101,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) requestedNote.value = takeRequestedNote(intent) - sharedText.value = takeSharedText(intent) + shared.value = takeShared(intent) } /** @@ -109,24 +119,49 @@ class MainActivity : ComponentActivity() { } /** - * Read the text a share or a text selection brought in, and CONSUME it. + * Read what a share or a text selection brought in, and CONSUME it. * * Consumed for the same reason [takeRequestedNote] is: the activity keeps the * intent it was launched with, so without removing the extras a rotation would * replay the share and mint the same note again, with nothing on screen to * explain where the duplicates were coming from. */ - private fun takeSharedText(intent: Intent?): String? { - val shared = + private fun takeShared(intent: Intent?): SharedIn? { + val incoming = when (intent?.action) { - Intent.ACTION_SEND -> intent.takeSendText() - Intent.ACTION_PROCESS_TEXT -> intent.takeProcessText() + Intent.ACTION_SEND -> SharedIn(intent.takeSendText(), intent.takeStreams()) + Intent.ACTION_SEND_MULTIPLE -> SharedIn(intent.takeSendText(), intent.takeStreams()) + Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList()) else -> null } - return shared?.takeIf { it.isNotBlank() } + return incoming?.takeIf { !it.text.isNullOrBlank() || it.files.isNotEmpty() } } } +/** + * What a share brought: words, files, or both — a photo shared from the gallery + * often comes with a caption. + * + * The files are content URIs the sending app granted this one read access to. The + * grant lasts while this activity does, which is longer than reading them takes. + */ +data class SharedIn( + val text: String?, + val files: List, +) + +/** The file or files a SEND or SEND_MULTIPLE carried, consumed like the text. */ +private fun Intent.takeStreams(): List { + val streams = + if (action == Intent.ACTION_SEND_MULTIPLE) { + IntentCompat.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java).orEmpty() + } else { + listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)) + } + removeExtra(Intent.EXTRA_STREAM) + return streams +} + /** * The shared text, with a subject line above it when the sender gave one. * @@ -175,14 +210,15 @@ private enum class Screen { BOARD, EDITOR, SYNC, TAGS } @Composable private fun App( core: Inkwell, + files: AttachmentFiles, requestedNote: MutableState, - sharedText: MutableState, + shared: MutableState, ) { val context = LocalContext.current val board: BoardViewModel = viewModel( factory = - BoardViewModel.factory(core) { + BoardViewModel.factory(core, readFile = files::read) { // Any store write can have moved the next reminder. Called on // the IO dispatcher by the view model, which is where it has to // be — this reads every note carrying a reminder. @@ -200,11 +236,11 @@ private fun App( } // Cleared the same way and for the same reason: without it every later - // recomposition would capture the shared text again as a new note. - LaunchedEffect(sharedText.value) { - sharedText.value?.let { - board.captureShared(it) - sharedText.value = null + // recomposition would capture the share again as a new note. + LaunchedEffect(shared.value) { + shared.value?.let { + board.captureShared(it.text, it.files) + shared.value = null } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt new file mode 100644 index 0000000..ce9cfb5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt @@ -0,0 +1,264 @@ +package com.fabledsword.inkwell.ui + +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface +import android.net.Uri +import android.provider.OpenableColumns +import android.util.LruCache +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.content.FileProvider +import com.fabledsword.inkwell.R +import com.fabledsword.inkwell.core.Attachment +import com.fabledsword.inkwell.core.Inkwell +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * A file picked or shared into the app, read and ready to attach — or the reason + * it could not be. + */ +sealed interface PickedFile { + // A plain class: a data class would compare `bytes` by identity, and nothing + // here compares two picked files anyway. + class Ready( + val name: String, + val mime: String, + val bytes: ByteArray, + ) : PickedFile + + data class Refused( + val reason: String, + ) : PickedFile +} + +/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */ +sealed interface Opener { + data class Ready( + val intent: Intent, + ) : Opener + + data class Failed( + val message: String, + ) : Opener +} + +/** + * Everything the app does with attachment FILES, as opposed to attachment rows: + * reading a picked file, decoding an image for display, and handing a file to + * another app to open. + * + * Holds the application context, never an Activity, so the board's view model can + * keep a reference to [read] without leaking a screen. + */ +class AttachmentFiles( + context: Context, + private val core: Inkwell, +) { + private val app = context.applicationContext + + /** + * Decoded images, keyed by hash and size. Sized in bytes against an eighth of + * the heap, which is the platform's own guidance for an in-memory image cache. + */ + private val images = + object : LruCache(cacheBytes()) { + override fun sizeOf( + key: String, + value: ImageBitmap, + ): Int = value.width * value.height * BYTES_PER_PIXEL + } + + /** + * Read a picked or shared file, all of it, with its name and type. + * + * Blocking; call it off the main thread. Refuses anything over + * [MAX_ATTACH_MB] — before reading it when the sender says how big it is — so + * a long video shared by mistake is a message rather than an out-of-memory + * crash. + */ + fun read(uri: Uri): PickedFile { + val (label, declared) = describe(uri) + val overDeclared = (declared ?: 0) > MAX_ATTACH_BYTES + val bytes = if (overDeclared) null else readCapped(uri) + return when { + overDeclared -> tooLarge(label) + bytes == null -> PickedFile.Refused(app.getString(R.string.attach_unreadable, label)) + bytes.size > MAX_ATTACH_BYTES -> tooLarge(label) + else -> PickedFile.Ready(label, app.contentResolver.getType(uri) ?: GENERIC_MIME, bytes) + } + } + + /** + * The image, decoded no larger than it will be drawn, or null when this device + * doesn't hold the file yet or it isn't an image Android can read. + * + * Blocking; call it off the main thread. + */ + fun image( + attachment: Attachment, + maxPx: Int, + ): ImageBitmap? { + val sha = attachment.sha256 ?: return null + val key = "$sha@$maxPx" + return images.get(key) + ?: core.blobPath(sha)?.let { decode(it, maxPx) }?.also { images.put(key, it) } + } + + /** + * An intent that hands the file to whatever app opens its type, or the reason + * there can't be one. + * + * Copied out of the blob store first, under its real name: the store names files + * by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a + * spreadsheet. The copy lives in the cache, which the system reclaims, and only + * that directory is shared (`res/xml/file_paths.xml`). + * + * Blocking; call it off the main thread, then start the intent from a screen. + */ + fun opener(attachment: Attachment): Opener { + val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File) + if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here)) + val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}") + val copy = File(dir, safeName(attachment.filename)) + return try { + dir.mkdirs() + if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true) + val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy) + val view = + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, attachment.mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + Opener.Ready(view) + } catch (e: IOException) { + Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty())) + } + } + + /** The name the sender gives the file and the size it declares, when it does. */ + private fun describe(uri: Uri): Pair { + val row = + try { + app.contentResolver.query(uri, COLUMNS, null, null, null)?.use { firstRow(it) } + } catch (expected: SecurityException) { + // No grant to read it at all; the read that follows says so. + null + } + val name = row?.first?.takeIf { it.isNotBlank() } ?: uri.lastPathSegment ?: FALLBACK_NAME + return name to row?.second + } + + /** + * The file's bytes, at most one past the cap — enough to know it is over without + * reading the rest — or null when it can't be read. + */ + private fun readCapped(uri: Uri): ByteArray? = + try { + app.contentResolver.openInputStream(uri)?.use { it.readUpTo(MAX_ATTACH_BYTES + 1) } + } catch (expected: IOException) { + null + } catch (expected: SecurityException) { + null + } + + private fun tooLarge(label: String) = + PickedFile.Refused(app.getString(R.string.attach_too_large, label, MAX_ATTACH_MB)) + + private companion object { + const val BYTES_PER_PIXEL = 4 + const val CACHE_FRACTION = 8 + const val OPEN_DIR = "open" + const val GENERIC_MIME = "application/octet-stream" + const val FALLBACK_NAME = "file" + val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + + /** How far each EXIF orientation is turned from upright. */ + val ROTATIONS = + mapOf( + ExifInterface.ORIENTATION_ROTATE_90 to 90f, + ExifInterface.ORIENTATION_ROTATE_180 to 180f, + ExifInterface.ORIENTATION_ROTATE_270 to 270f, + ) + + /** DISPLAY_NAME and SIZE from the first row of a [COLUMNS] query; either may be absent. */ + fun firstRow(cursor: Cursor): Pair? = + if (cursor.moveToFirst()) { + cursor.getString(0) to (if (cursor.isNull(1)) null else cursor.getLong(1)) + } else { + null + } + + fun cacheBytes(): Int = + (Runtime.getRuntime().maxMemory() / CACHE_FRACTION) + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt() + + fun decode( + path: String, + maxPx: Int, + ): ImageBitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val options = + BitmapFactory.Options().apply { + inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, maxPx) + } + return BitmapFactory.decodeFile(path, options)?.let { upright(it, path).asImageBitmap() } + } + + /** Camera photos are stored sideways with a tag saying so; BitmapFactory ignores it. */ + fun upright( + bitmap: Bitmap, + path: String, + ): Bitmap { + val orientation = + try { + ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, 0) + } catch (expected: IOException) { + // No readable EXIF: draw it as stored. + 0 + } + val degrees = ROTATIONS[orientation] ?: return bitmap + val turn = Matrix().apply { postRotate(degrees) } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, turn, true) + } + } +} + +/** + * The app's [AttachmentFiles], for the card and the editor. Null in previews and + * anywhere it was never provided, where attachments draw as plain file rows. + */ +val LocalAttachmentFiles = staticCompositionLocalOf { null } + +/** + * The largest file the phone will attach. Read whole into memory and copied once + * into the core, so the cap is about the phone's heap. The server has its own + * limit (25 MB by default, `max_attachment_mb`); a file over that one is kept here + * and its upload reported as refused. + */ +const val MAX_ATTACH_MB = 50 +private const val MAX_ATTACH_BYTES = MAX_ATTACH_MB * 1024 * 1024 + +/** `InputStream.readNBytes(int)` is API 33; this app supports 26. */ +private fun InputStream.readUpTo(limit: Int): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0 + while (total < limit) { + val read = read(buffer, 0, minOf(buffer.size, limit - total)) + if (read < 0) break + out.write(buffer, 0, read) + total += read + } + return out.toByteArray() +} diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentRules.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentRules.kt new file mode 100644 index 0000000..06fae24 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentRules.kt @@ -0,0 +1,56 @@ +package com.fabledsword.inkwell.ui + +import java.util.Locale +import kotlin.math.max +import kotlin.math.roundToInt + +// The attachment decisions that need no Android: which files draw as pictures, how +// far to scale a decode, what to name a copy, how to print a size. Kept apart from +// AttachmentFiles so the JVM unit tests can load them without a device. + +/** + * Whether a type is drawn as a picture rather than offered as a file. SVG is + * excluded on every surface: it is a document that can carry script (#1981). + * The same rule as `rendersInline` in the web's `notes/attachments.ts`. + */ +fun rendersInline(mime: String): Boolean { + val type = mime.lowercase().substringBefore(';').trim() + return type.startsWith("image/") && type != "image/svg+xml" +} + +/** + * The power-of-two step BitmapFactory decodes at, so the result's longer side is + * still at least [maxPx]: never upscaled on screen, never decoded at full camera + * resolution for a thumbnail. + */ +fun sampleSize( + width: Int, + height: Int, + maxPx: Int, +): Int { + val longest = max(width, height) + var sample = 1 + while (longest / (sample * 2) >= maxPx) sample *= 2 + return sample +} + +/** A name safe to create in the cache: no path separators, never empty. */ +fun safeName(filename: String?): String { + val base = + filename + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.trim() + .orEmpty() + return base.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "file" +} + +/** "12 KB", "3.4 MB" — the same rounding as `fmtSize` in the web's NoteEditor.vue. */ +fun sizeLabel(bytes: Long): String = + when { + bytes < KIB -> "$bytes B" + bytes < KIB * KIB -> "${(bytes / KIB).roundToInt()} KB" + else -> "%.1f MB".format(Locale.ROOT, bytes / (KIB * KIB)) + } + +private const val KIB = 1024.0 diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt new file mode 100644 index 0000000..3dfef29 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt @@ -0,0 +1,336 @@ +package com.fabledsword.inkwell.ui + +import android.content.ActivityNotFoundException +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.fabledsword.inkwell.R +import com.fabledsword.inkwell.core.Attachment +import com.fabledsword.inkwell.core.LinkPreview +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val ATTACHMENT_RADIUS = 8.dp + +/** + * Decoded no larger than this on the card. A board column on a phone is around + * 180dp wide, which is about 540px at the common densities, so this is sharp on + * every screen the app runs on without decoding a 12-megapixel photo for it. + */ +private const val CARD_IMAGE_PX = 640 + +/** The editor draws images at the sheet's full width. */ +private const val EDITOR_IMAGE_PX = 1440 + +/** + * The narrowest an image may draw, as width over height. A tall screenshot drawn + * at its own ratio would push the rest of the card off the board, so it is cropped + * to 3:4 instead; anything wider than that draws whole. + */ +private const val MIN_ASPECT = 0.75f + +/** How many file names the card lists before it says "and N more". */ +private const val CARD_FILE_ROWS = 2 + +/** + * A note's attachments on its board card: the first image as a picture, then the + * other files by name. + * + * One picture, not a gallery. The card is a glance at the note, and a strip of + * thumbnails would make an image note the tallest thing on the board whatever its + * words say. The editor shows them all. + */ +@Composable +fun CardAttachments(attachments: List) { + val (images, files) = attachments.partition { rendersInline(it.mime) } + images.firstOrNull()?.let { first -> + Spacer(Modifier.height(8.dp)) + AttachmentImage(attachment = first, maxPx = CARD_IMAGE_PX) { + FileRow(attachment = first) + } + } + val rest = images.drop(1) + files + rest.take(CARD_FILE_ROWS).forEach { file -> + Spacer(Modifier.height(4.dp)) + FileRow(attachment = file) + } + if (rest.size > CARD_FILE_ROWS) { + Text( + text = stringResource(R.string.attach_more, rest.size - CARD_FILE_ROWS), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +/** + * Every attachment, in the editor: images at full width, files as rows, each one + * opening with the system and each removable. + * + * A file the server refused says so under it, in its own words — the upload is + * not retried, so without this a file that never reaches the other devices would + * look exactly like one that did. + */ +@Composable +fun EditorAttachments( + attachments: List, + readOnly: Boolean, + onAction: (EditorAction) -> Unit, +) { + val open = rememberOpener() + Column(modifier = Modifier.padding(top = 12.dp)) { + attachments.forEach { attachment -> + val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) } + Box(modifier = Modifier.padding(vertical = 4.dp)) { + if (rendersInline(attachment.mime)) { + AttachmentImage( + attachment = attachment, + maxPx = EDITOR_IMAGE_PX, + onClick = { open(attachment) }, + ) { + FileRow(attachment = attachment, onClick = { open(attachment) }) + } + } else { + FileRow(attachment = attachment, onClick = { open(attachment) }) + } + if (!readOnly) { + IconButton( + onClick = remove, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + ) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.attach_remove), + ) + } + } + } + attachment.uploadError?.let { reason -> + Text( + text = stringResource(R.string.attach_refused, reason), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +/** + * The note's link previews in the editor, each dismissable. + * + * Dismissing is the only control: the server made the preview and will not make + * it again, so removing one is a decision about this note rather than a refresh. + */ +@Composable +fun EditorPreviews( + previews: List, + readOnly: Boolean, + onAction: (EditorAction) -> Unit, +) { + Column(modifier = Modifier.padding(top = 12.dp)) { + previews.forEach { preview -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 2.dp), + ) { + LinkPreviewCard(preview = preview, compact = true, modifier = Modifier.weight(1f)) + if (!readOnly) { + IconButton(onClick = { onAction(EditorAction.RemovePreview(preview.id)) }) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.preview_remove), + ) + } + } + } + } + } +} + +/** Loading, drawn, or not drawable (not downloaded yet, or not an image Android reads). */ +private sealed interface ImageLoad { + data object Loading : ImageLoad + + data class Ready( + val bitmap: ImageBitmap, + ) : ImageLoad + + data object Missing : ImageLoad +} + +/** + * An attachment drawn as a picture, decoded off the main thread. + * + * [fallback] is what draws when it can't be: a file this device hasn't downloaded + * yet, or bytes that don't decode. It is a file row, so the note still shows that + * something is attached instead of a gap. + */ +@Composable +private fun AttachmentImage( + attachment: Attachment, + maxPx: Int, + onClick: (() -> Unit)? = null, + fallback: @Composable () -> Unit, +) { + val files = LocalAttachmentFiles.current + val load by produceState(ImageLoad.Loading, attachment.sha256, maxPx, files) { + val bitmap = files?.let { withContext(Dispatchers.IO) { it.image(attachment, maxPx) } } + value = bitmap?.let { ImageLoad.Ready(it) } ?: ImageLoad.Missing + } + val shape = RoundedCornerShape(ATTACHMENT_RADIUS) + when (val state = load) { + ImageLoad.Loading -> + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(4f / 3f) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + is ImageLoad.Ready -> { + val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT) + val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier + Image( + bitmap = state.bitmap, + contentDescription = attachment.filename, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(ratio) + .clip(shape) + .then(tap), + ) + } + ImageLoad.Missing -> fallback() + } +} + +/** A file by name and size, behind a paperclip. Tappable in the editor, not on the card. */ +@Composable +private fun FileRow( + attachment: Attachment, + onClick: (() -> Unit)? = null, +) { + val shape = RoundedCornerShape(ATTACHMENT_RADIUS) + val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .then(tap) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_attach), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = attachment.filename?.takeIf { it.isNotBlank() } ?: stringResource(R.string.attach_unnamed), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + attachment.size?.let { bytes -> + Text( + text = sizeLabel(bytes), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Clear of the editor's remove button, which sits over this corner. + modifier = Modifier.padding(start = 8.dp, end = if (onClick != null) 32.dp else 0.dp), + ) + } + } +} + +/** + * Open an attachment with whatever app handles its type, or say why not. + * + * The copy out of the blob store runs on IO; the activity starts on the main + * thread, from the screen's own context, so the viewer opens over this app rather + * than in a task of its own. + */ +@Composable +private fun rememberOpener(): (Attachment) -> Unit { + val files = LocalAttachmentFiles.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + return { attachment -> + if (files != null) { + scope.launch { + when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) { + is Opener.Ready -> start(context, opener) + is Opener.Failed -> toast(context, opener.message) + } + } + } + } +} + +private fun start( + context: Context, + opener: Opener.Ready, +) { + try { + context.startActivity(opener.intent) + } catch (expected: ActivityNotFoundException) { + toast(context, context.getString(R.string.attach_no_app)) + } +} + +private fun toast( + context: Context, + message: String, +) = Toast.makeText(context, message, Toast.LENGTH_SHORT).show() diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt index 7b7d933..52becf5 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt @@ -1,5 +1,6 @@ package com.fabledsword.inkwell.ui +import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -110,6 +111,11 @@ class BoardViewModel( * view model's `onStoreChanged`. */ private val onRemindersChanged: () -> Unit = {}, + /** + * Read a picked or shared file. Blocking; only ever called on the IO dispatcher. + * A function rather than a Context for the same reason as the callback above. + */ + private val readFile: (Uri) -> PickedFile = { PickedFile.Refused(FALLBACK_ERROR) }, ) : ViewModel() { var state by mutableStateOf(BoardState()) private set @@ -270,16 +276,23 @@ class BoardViewModel( * back leaves it alone, and adding a line of context is optional rather than * load-bearing. */ - fun captureShared(text: String) { - val content = text.trim() - if (content.isEmpty()) return + fun captureShared( + text: String?, + files: List = emptyList(), + ) { + val content = text?.trim().orEmpty() + if (content.isEmpty() && files.isEmpty()) return // A share is a new sitting even if the editor was already open on // something, so the field must be re-keyed onto what arrives. `createFrom // Draft` deliberately does not bump this — it is written for the autosave // case, where re-keying mid-typing would be the bug. draftDismissed = false state = state.copy(editingSession = state.editingSession + 1) - createFromDraft(content) + // A shared photo arrives with no words, and the note it makes is still a + // note: the picture is its content. + createFromDraft(content, allowEmpty = files.isNotEmpty()) { created -> + if (files.isNotEmpty()) onEditorAction(created, EditorAction.Attach(files)) + } } /** @@ -310,6 +323,10 @@ class BoardViewModel( } EditorAction.DismissError -> dismissError() is EditorAction.SaveText -> createFromDraft(action.body) + // Attaching to an empty draft is a note with a file and no words yet, so + // it is the one action that may create a note with no text. + is EditorAction.Attach -> + createFromDraft(draft.body, allowEmpty = true) { created -> onEditorAction(created, action) } // Colour, reminder, pin, labels: attributes OF a note, so there has to be // a note. With autosave at a second, "typed something" is true by the time // anyone reaches the toolbar; before that there is nothing to attribute. @@ -374,7 +391,7 @@ class BoardViewModel( * mutation that lands between a tap and its dispatch cannot redirect the * action at a different note. * - * Both suppressions have ONE cause: [EditorAction] has twenty variants, so a + * Both suppressions have ONE cause: [EditorAction] has over twenty variants, so a * total function over it is twenty branches and sixty-odd lines no matter how * it is written. Splitting it into sub-dispatchers is the only way to shorten * it, and each of those would need an `else` — which throws away precisely the @@ -446,6 +463,35 @@ class BoardViewModel( id, action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence, ) + + is EditorAction.Attach -> attach(id, action.uris) + is EditorAction.RemoveAttachment -> mutate { it.deleteAttachment(id, action.attachmentId) } + is EditorAction.RemovePreview -> mutate { it.deletePreview(id, action.previewId) } + } + } + + /** + * Read each file and attach it, one at a time. + * + * A file that can't be read, or is too large, is skipped and named afterwards + * rather than failing the rest: sharing four photos where one is a video should + * still attach the three. + */ + private fun attach( + id: String, + uris: List, + ) { + val refused = mutableListOf() + mutate(notice = { refused.takeIf { it.isNotEmpty() }?.joinToString("\n") }) { core -> + uris.fold(null as Note?) { latest, uri -> + when (val file = readFile(uri)) { + is PickedFile.Ready -> core.addAttachment(id, file.name, file.mime, file.bytes) + is PickedFile.Refused -> { + refused += file.reason + latest + } + } + } } } @@ -482,6 +528,9 @@ class BoardViewModel( */ private fun mutate( closeEditor: Boolean = false, + // Something to say once the write has landed, shown where an error would be. + // Read after `block` has run, so the block can decide it. + notice: () -> String? = { null }, block: (Inkwell) -> Note?, ) { viewModelScope.launch { @@ -506,7 +555,7 @@ class BoardViewModel( // which is exactly what ticking a checkbox on a card did. editing = if (closeEditor) null else state.editing?.let { updated ?: it }, saving = false, - error = null, + error = notice(), ) } catch (e: Exception) { // Broad by intent, as elsewhere: the core reports every failure @@ -548,12 +597,13 @@ class BoardViewModel( fun factory( core: Inkwell, + readFile: (Uri) -> PickedFile, onRemindersChanged: () -> Unit, ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = - BoardViewModel(core, onRemindersChanged) as T + BoardViewModel(core, onRemindersChanged, readFile) as T } } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorAction.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorAction.kt index b3216f0..32d28e7 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorAction.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorAction.kt @@ -1,5 +1,7 @@ package com.fabledsword.inkwell.ui +import android.net.Uri + /** * Everything the editor can ask for, as one type. * @@ -93,4 +95,22 @@ sealed interface EditorAction { data class SetRecurrence( val rule: String?, ) : EditorAction + + /** + * Attach files picked on this phone or shared into the app. + * + * URIs rather than bytes: reading them is blocking I/O, and the view model does + * it on the IO dispatcher where a failure can still become the error banner. + */ + data class Attach( + val uris: List, + ) : EditorAction + + data class RemoveAttachment( + val attachmentId: String, + ) : EditorAction + + data class RemovePreview( + val previewId: String, + ) : EditorAction } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt index b6f33ab..b456fbe 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.fabledsword.inkwell.R @@ -78,6 +79,7 @@ fun EditorTopBar( onClose: () -> Unit, onStartChecklist: () -> Unit, onPicker: (Picker) -> Unit, + onAttach: () -> Unit, onConfirmDelete: () -> Unit, onAction: (EditorAction) -> Unit, ) { @@ -113,6 +115,15 @@ fun EditorTopBar( contentDescription = stringResource(R.string.editor_add_checklist), ) } + // On the bar rather than in the overflow: attaching a photo is + // something people look for, and a menu of words is where it would + // not be found. + IconButton(onClick = onAttach) { + Icon( + painter = painterResource(R.drawable.ic_attach), + contentDescription = stringResource(R.string.editor_attach), + ) + } } OverflowMenu( note = note, diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt index aba37d1..8ddefee 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt @@ -151,9 +151,15 @@ fun NoteCard( } } + // Under the words and the links, like the web's card: a photo is often what a + // note is ABOUT, but its first line is still its name. + if (note.attachments.isNotEmpty()) { + CardAttachments(attachments = note.attachments) + } + // A note with nothing in it still has to occupy the board legibly — otherwise // it reads as a rendering bug. - if (note.body.isBlank() && note.previews.isEmpty()) { + if (note.body.isBlank() && note.previews.isEmpty() && note.attachments.isEmpty()) { Text( text = stringResource(R.string.board_empty_note), style = MaterialTheme.typography.bodyMedium, diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt index d108443..cc55f8d 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt @@ -1,6 +1,8 @@ package com.fabledsword.inkwell.ui import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -132,6 +134,14 @@ fun NoteEditorScreen( BackHandler(onBack = leave) + // The system picker, for any type: a note can carry a PDF as readily as a photo. + // Leaving for it stops this screen, so FlushOnStop has already saved the text by + // the time the files come back. + val pickFiles = + rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris -> + if (uris.isNotEmpty()) onAction(EditorAction.Attach(uris)) + } + // Leaving the APP is not closing the editor, so the text has to be saved // without the screen being torn down. Losing a paragraph to an incoming call // is exactly the failure that makes someone stop trusting a notes app. @@ -174,6 +184,7 @@ fun NoteEditorScreen( focus = id }, onPicker = { picker = it }, + onAttach = { pickFiles.launch(ANY_TYPE) }, onConfirmDelete = { confirmingDelete = true }, onAction = onAction, ) @@ -243,6 +254,14 @@ fun NoteEditorScreen( onAction = onAction, ) } + + if (note.attachments.isNotEmpty()) { + EditorAttachments(attachments = note.attachments, readOnly = readOnly, onAction = onAction) + } + + if (note.previews.isNotEmpty()) { + EditorPreviews(previews = note.previews, readOnly = readOnly, onAction = onAction) + } } } } @@ -307,6 +326,9 @@ private fun EditorOverlays( */ private const val AUTOSAVE_IDLE_MS = 1_000L +/** What the file picker offers: everything. The server takes any type. */ +private const val ANY_TYPE = "*/*" + /** * The card's top corner radius — Material's extra-large, which is what a bottom * sheet uses. Same shape as the capture surface this replaced, on purpose. diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncText.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncText.kt index a83809e..2f9a4e3 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncText.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/SyncText.kt @@ -32,6 +32,8 @@ fun syncSummary(outcome: SyncOutcome): String { if (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent) if (received > 0) parts += stringResource(R.string.sync_summary_received, received) if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs) + val uploaded = outcome.push.uploaded.toInt() + if (uploaded > 0) parts += pluralStringResource(R.plurals.sync_summary_uploaded, uploaded, uploaded) val line = if (parts.isEmpty()) { @@ -44,11 +46,13 @@ fun syncSummary(outcome: SyncOutcome): String { // rather than an error — but saying nothing would leave a missing image // looking like data loss. val failed = outcome.pull.blobsFailed.toInt() - return if (failed > 0) { - line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed) - } else { - line - } + val notUploaded = outcome.push.uploadFailed.toInt() + val notes = mutableListOf(line) + if (failed > 0) notes += pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed) + // A refused upload is recorded on its attachment and shown in the editor; this + // line is what sends someone looking. + if (notUploaded > 0) notes += pluralStringResource(R.plurals.sync_summary_upload_failed, notUploaded, notUploaded) + return notes.joinToString(" ") } /** diff --git a/android/app/src/main/res/drawable/ic_attach.xml b/android/app/src/main/res/drawable/ic_attach.xml new file mode 100644 index 0000000..5c4da16 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_attach.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3de5e11..a177730 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -59,6 +59,20 @@ Restore Cancel + + Attach a file + Remove attachment + Unnamed file + +%1$d more + Not uploaded: %1$s + %1$s is over %2$d MB, too large to attach from the phone. + Couldn\'t read %1$s. + This file hasn\'t downloaded to this phone yet. Sync, then try again. + No app on this phone opens this kind of file. + Couldn\'t open the file: %1$s + Remove link preview + Delete forever @@ -247,6 +261,14 @@ %d attachment didn\'t download — it\'ll retry on the next sync. %d attachments didn\'t download — they\'ll retry on the next sync. + + uploaded %d file + uploaded %d files + + + %d file didn\'t upload. If the server refused it, its note says why; otherwise it\'ll retry. + %d files didn\'t upload. Any the server refused say why on their note; the rest will retry. + Dismiss diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..167f6d6 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/test/java/com/fabledsword/inkwell/ui/AttachmentRulesTest.kt b/android/app/src/test/java/com/fabledsword/inkwell/ui/AttachmentRulesTest.kt new file mode 100644 index 0000000..86ca853 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/inkwell/ui/AttachmentRulesTest.kt @@ -0,0 +1,46 @@ +package com.fabledsword.inkwell.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttachmentRulesTest { + @Test + fun `raster images draw inline and SVG never does`() { + assertTrue(rendersInline("image/png")) + assertTrue(rendersInline("image/jpeg")) + assertTrue(rendersInline("IMAGE/WEBP; charset=binary")) + assertFalse(rendersInline("image/svg+xml")) + assertFalse(rendersInline("Image/SVG+XML")) + assertFalse(rendersInline("application/pdf")) + assertFalse(rendersInline("")) + } + + @Test + fun `a decode is scaled down but never below the size it is drawn at`() { + assertEquals(1, sampleSize(width = 600, height = 400, maxPx = 640)) + assertEquals(1, sampleSize(width = 1279, height = 900, maxPx = 640)) + assertEquals(2, sampleSize(width = 1280, height = 900, maxPx = 640)) + // A 12-megapixel portrait photo for a card: the longer side decides. + assertEquals(4, sampleSize(width = 3024, height = 4032, maxPx = 640)) + assertTrue(4032 / sampleSize(3024, 4032, 640) >= 640) + } + + @Test + fun `a cache copy's name cannot leave its directory and is never empty`() { + assertEquals("receipt.pdf", safeName("receipt.pdf")) + assertEquals("passwd", safeName("../../etc/passwd")) + assertEquals("evil.txt", safeName("C:\\temp\\evil.txt")) + assertEquals("file", safeName("..")) + assertEquals("file", safeName(" ")) + assertEquals("file", safeName(null)) + } + + @Test + fun `sizes print like the web prints them`() { + assertEquals("512 B", sizeLabel(512)) + assertEquals("2 KB", sizeLabel(1536)) + assertEquals("3.5 MB", sizeLabel(3_670_016)) + } +} diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 8563cb0..2033599 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -392,6 +392,56 @@ impl Inkwell { .map_err(CoreError::store) } + // ────────────────────────── attachments and previews ────────────────────────── + + /// Attach a file. The bytes go into the blob store now and up to the server on + /// the next sync that can reach one, so attaching works with no network. + /// + /// The bytes cross the FFI as one buffer. Kotlin caps what it reads before + /// calling, because this copies the whole file into Rust's memory once. + pub fn add_attachment( + &self, + note_id: String, + filename: String, + mime: String, + bytes: Vec, + ) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::add_attachment(&conn, &self.blobs, ¬e_id, &filename, &mime, &bytes) + .map(Note::from) + .map_err(CoreError::store) + } + + pub fn delete_attachment( + &self, + note_id: String, + attachment_id: String, + ) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::delete_attachment(&conn, ¬e_id, &attachment_id) + .map(Note::from) + .map_err(CoreError::store) + } + + pub fn delete_preview(&self, note_id: String, preview_id: String) -> Result { + let conn = self.db.conn().map_err(CoreError::store)?; + local::store::delete_preview(&conn, ¬e_id, &preview_id) + .map(Note::from) + .map_err(CoreError::store) + } + + /// Where this device holds an attachment's bytes, or None when it doesn't yet + /// (still downloading, or the download failed). + /// + /// A path rather than the bytes, so Kotlin can decode an image at the size it + /// will be drawn instead of pulling the full file across the FFI for a thumbnail. + pub fn blob_path(&self, sha256: String) -> Option { + self.blobs + .path(&sha256) + .filter(|p| p.is_file()) + .map(|p| p.to_string_lossy().into_owned()) + } + // ─────────────────────────────── sync ──────────────────────────────── pub fn sync_status(&self) -> Result { @@ -882,6 +932,56 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// A file attached here lands in the blob store, is findable by its hash, and + /// leaves both the note and the board's view of it when removed. + #[test] + fn an_attached_file_is_stored_found_and_removable() { + let dir = scratch_dir(); + let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open"); + let note = app.create_note(draft("Receipt")).expect("create"); + + let attached = app + .add_attachment( + note.id.clone(), + "receipt.png".into(), + "image/png".into(), + b"not really a png".to_vec(), + ) + .expect("attach"); + assert_eq!(attached.attachments.len(), 1); + let att = &attached.attachments[0]; + assert_eq!(att.filename.as_deref(), Some("receipt.png")); + assert_eq!(att.mime, "image/png"); + assert_eq!(att.upload_error, None); + + let sha = att.sha256.clone().expect("a stored file carries its hash"); + let path = app.blob_path(sha.clone()).expect("the bytes are on disk"); + assert_eq!(std::fs::read(path).unwrap(), b"not really a png"); + assert_eq!( + app.blob_path("0".repeat(64)), + None, + "an unknown hash has no path" + ); + + let after = app + .delete_attachment(note.id.clone(), att.id.clone()) + .expect("remove"); + assert!(after.attachments.is_empty()); + + assert!( + app.add_attachment( + "missing".into(), + "a.txt".into(), + "text/plain".into(), + vec![1] + ) + .is_err(), + "attaching to a note that doesn't exist is refused" + ); + + std::fs::remove_dir_all(&dir).ok(); + } + /// Snooze writes a future instant from the CORE's clock; complete clears it. #[test] fn reminders_can_be_snoozed_and_completed() {