Attached images open full size and copy back out (#6198)

Scribe #6198 "An attached image can't be viewed large or copied back out".

Web and desktop: clicking an image in the editor opens it full size, with
Copy image (re-encoded to PNG, the one type every clipboard accepts),
Download and Close. Copy hides where the browser has no async clipboard,
such as a server reached over plain http; right-clicking the full-size
image still copies there.

Android: long-pressing an image in the editor copies it to the clipboard;
a tap still opens it full size in the phone's viewer. Opening and copying
share one hand-out that copies the file out of the blob store behind a
FileProvider URI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 17:14:32 -04:00
co-authored by Claude Opus 5.5
parent 142fafe0ed
commit 19487b079a
6 changed files with 235 additions and 24 deletions
@@ -1,5 +1,7 @@
package com.fabledsword.inkwell.ui
import android.content.ClipData
import android.content.ClipDescription
import android.content.Context
import android.content.Intent
import android.database.Cursor
@@ -10,6 +12,7 @@ import android.media.ExifInterface
import android.net.Uri
import android.provider.OpenableColumns
import android.util.LruCache
import androidx.annotation.StringRes
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
@@ -40,15 +43,18 @@ sealed interface PickedFile {
) : 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
/**
* What [AttachmentFiles] made to hand a file to another app — an intent to start, a
* clip for the clipboard — or why there isn't one.
*/
sealed interface Handout<out T> {
data class Ready<T>(
val value: T,
) : Handout<T>
data class Failed(
val message: String,
) : Opener
) : Handout<Nothing>
}
/**
@@ -117,29 +123,56 @@ class AttachmentFiles(
* An intent that hands the file to whatever app opens its type, or the reason
* there can't be one.
*
* Blocking; call it off the main thread, then start the intent from a screen.
*/
fun opener(attachment: Attachment): Handout<Intent> =
handOut(attachment, R.string.attach_open_failed) { uri ->
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, attachment.mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
/**
* A clip that puts the file itself on the clipboard, so it pastes into another
* app as the picture rather than as a path.
*
* The clip carries the attachment's own type: built from the URI alone, its type
* would be guessed from the file name's extension, and a pasted image saved
* without one would paste as an unknown file. The clipboard grants whichever app
* pastes it read access to the one URI.
*
* Blocking; call it off the main thread.
*/
fun clip(attachment: Attachment): Handout<ClipData> =
handOut(attachment, R.string.attach_copy_failed) { uri ->
val label = attachment.filename.orEmpty()
ClipData(ClipDescription(label, arrayOf(attachment.mime)), ClipData.Item(uri))
}
/**
* The file as a URI another app may read, made into whatever [make] builds from
* it.
*
* 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 {
private fun <T> handOut(
attachment: Attachment,
@StringRes failed: Int,
make: (Uri) -> T,
): Handout<T> {
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here))
if (source == null) return Handout.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)
Handout.Ready(make(FileProvider.getUriForFile(app, "${app.packageName}.files", copy)))
} catch (e: IOException) {
Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty()))
Handout.Failed(app.getString(failed, e.message ?: attachment.filename.orEmpty()))
}
}
@@ -1,12 +1,16 @@
package com.fabledsword.inkwell.ui
import android.content.ActivityNotFoundException
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
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.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -33,8 +37,10 @@ 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -115,6 +121,7 @@ fun EditorAttachments(
onAction: (EditorAction) -> Unit,
) {
val open = rememberOpener()
val copy = rememberCopier()
Column(modifier = Modifier.padding(top = 12.dp)) {
attachments.forEach { attachment ->
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
@@ -124,6 +131,7 @@ fun EditorAttachments(
attachment = attachment,
maxPx = EDITOR_IMAGE_PX,
onClick = { open(attachment) },
onLongClick = { copy(attachment) },
) {
FileRow(attachment = attachment, onClick = { open(attachment) })
}
@@ -205,6 +213,9 @@ private sealed interface ImageLoad {
/**
* An attachment drawn as a picture, decoded off the main thread.
*
* In the editor a tap opens it full size in the phone's viewer and a long press
* copies it, the same gesture that copies an image in a browser.
*
* [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.
@@ -214,6 +225,7 @@ private fun AttachmentImage(
attachment: Attachment,
maxPx: Int,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
fallback: @Composable () -> Unit,
) {
val files = LocalAttachmentFiles.current
@@ -234,7 +246,15 @@ private fun AttachmentImage(
)
is ImageLoad.Ready -> {
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
val tap =
onClick?.let { click ->
Modifier.combinedClickable(
onClickLabel = stringResource(R.string.attach_view),
onLongClickLabel = onLongClick?.let { stringResource(R.string.attach_copy) },
onLongClick = onLongClick,
onClick = click,
)
} ?: Modifier
Image(
bitmap = state.bitmap,
contentDescription = attachment.filename,
@@ -311,8 +331,39 @@ private fun rememberOpener(): (Attachment) -> Unit {
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)
is Handout.Ready -> start(context, opener.value)
is Handout.Failed -> toast(context, opener.message)
}
}
}
}
}
/**
* Put an image on the clipboard, or say why not.
*
* Android 13 and later show their own confirmation when anything is copied, so the
* toast is only for the versions that don't — two notices for one copy reads as two
* copies.
*/
@Composable
private fun rememberCopier(): (Attachment) -> Unit {
val files = LocalAttachmentFiles.current
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
return { attachment ->
if (files != null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
scope.launch {
when (val clip = withContext(Dispatchers.IO) { files.clip(attachment) }) {
is Handout.Ready -> {
context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip.value)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
toast(context, context.getString(R.string.attach_copied))
}
}
is Handout.Failed -> toast(context, clip.message)
}
}
}
@@ -321,10 +372,10 @@ private fun rememberOpener(): (Attachment) -> Unit {
private fun start(
context: Context,
opener: Opener.Ready,
intent: Intent,
) {
try {
context.startActivity(opener.intent)
context.startActivity(intent)
} catch (expected: ActivityNotFoundException) {
toast(context, context.getString(R.string.attach_no_app))
}
@@ -116,6 +116,10 @@
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
<string name="attach_no_app">No app on this phone opens this kind of file.</string>
<string name="attach_open_failed">Couldn\'t open the file: %1$s</string>
<string name="attach_view">Open full size</string>
<string name="attach_copy">Copy image</string>
<string name="attach_copied">Image copied</string>
<string name="attach_copy_failed">Couldn\'t copy the image: %1$s</string>
<string name="preview_remove">Remove link preview</string>
<!-- Deleting for good is the only thing in the app that cannot be undone, so