AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -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.
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!--
|
||||
The label is what appears in the text-selection menu beside Copy and
|
||||
@@ -164,6 +174,22 @@
|
||||
PackageInstaller. Without it a failed install would be indistinguishable
|
||||
from someone declining the dialog (Scribe #2438).
|
||||
-->
|
||||
<!--
|
||||
Hands an attachment to the app that opens its type. Not exported, as a
|
||||
FileProvider must not be: access is granted one URI at a time, on the
|
||||
intent that opens it. It serves only the cache copies listed in
|
||||
res/xml/file_paths.xml, never the note store.
|
||||
-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name=".UpdateReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -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<String?>(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<String?>(null)
|
||||
private val shared = mutableStateOf<SharedIn?>(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<Uri>,
|
||||
)
|
||||
|
||||
/** The file or files a SEND or SEND_MULTIPLE carried, consumed like the text. */
|
||||
private fun Intent.takeStreams(): List<Uri> {
|
||||
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<String?>,
|
||||
sharedText: MutableState<String?>,
|
||||
shared: MutableState<SharedIn?>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, ImageBitmap>(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<String, Long?> {
|
||||
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<String?, Long?>? =
|
||||
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<AttachmentFiles?> { 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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<Attachment>) {
|
||||
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<Attachment>,
|
||||
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<LinkPreview>,
|
||||
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>(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()
|
||||
@@ -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<Uri> = 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<Uri>,
|
||||
) {
|
||||
val refused = mutableListOf<String>()
|
||||
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 <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
BoardViewModel(core, onRemindersChanged) as T
|
||||
BoardViewModel(core, onRemindersChanged, readFile) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Uri>,
|
||||
) : EditorAction
|
||||
|
||||
data class RemoveAttachment(
|
||||
val attachmentId: String,
|
||||
) : EditorAction
|
||||
|
||||
data class RemovePreview(
|
||||
val previewId: String,
|
||||
) : EditorAction
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The Material paperclip ("attach_file"), for the editor's Attach button and the
|
||||
file rows under a note.
|
||||
|
||||
A drawable rather than an Icons.* constant because material-icons-core does not
|
||||
carry it, and the extended set is a multi-megabyte dependency for one glyph.
|
||||
Tinted by whatever draws it, like every other icon in the toolbar.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z" />
|
||||
</vector>
|
||||
@@ -59,6 +59,20 @@
|
||||
<string name="editor_restore">Restore</string>
|
||||
<string name="editor_cancel">Cancel</string>
|
||||
|
||||
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
|
||||
sync, so nothing here talks about the network. -->
|
||||
<string name="editor_attach">Attach a file</string>
|
||||
<string name="attach_remove">Remove attachment</string>
|
||||
<string name="attach_unnamed">Unnamed file</string>
|
||||
<string name="attach_more">+%1$d more</string>
|
||||
<string name="attach_refused">Not uploaded: %1$s</string>
|
||||
<string name="attach_too_large">%1$s is over %2$d MB, too large to attach from the phone.</string>
|
||||
<string name="attach_unreadable">Couldn\'t read %1$s.</string>
|
||||
<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="preview_remove">Remove link preview</string>
|
||||
|
||||
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
||||
the copy says exactly that rather than asking "Are you sure?". -->
|
||||
<string name="editor_delete_forever">Delete forever</string>
|
||||
@@ -247,6 +261,14 @@
|
||||
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
|
||||
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
|
||||
</plurals>
|
||||
<plurals name="sync_summary_uploaded">
|
||||
<item quantity="one">uploaded %d file</item>
|
||||
<item quantity="other">uploaded %d files</item>
|
||||
</plurals>
|
||||
<plurals name="sync_summary_upload_failed">
|
||||
<item quantity="one">%d file didn\'t upload. If the server refused it, its note says why; otherwise it\'ll retry.</item>
|
||||
<item quantity="other">%d files didn\'t upload. Any the server refused say why on their note; the rest will retry.</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_dismiss">Dismiss</string>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
What the FileProvider may hand to another app: ONLY the copies made to open an
|
||||
attachment (AttachmentFiles.open), never the store or the blob directory itself.
|
||||
A viewer gets read access to the one file it was asked to show.
|
||||
-->
|
||||
<paths>
|
||||
<cache-path name="open" path="open/" />
|
||||
</paths>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
) -> Result<Note, CoreError> {
|
||||
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<Note, CoreError> {
|
||||
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<Note, CoreError> {
|
||||
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<String> {
|
||||
self.blobs
|
||||
.path(&sha256)
|
||||
.filter(|p| p.is_file())
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
// ─────────────────────────────── sync ────────────────────────────────
|
||||
|
||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user