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() {