android: Share → ThoughtSync, and a "New note" entry in the selection toolbar
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m21s

Capture without opening the app first — the input half of #1899. Two ways in:
the share sheet from anywhere, and the text-selection toolbar in any app's
text field.

## The note is created, not pre-filled

The obvious build is "open the editor on a draft holding the shared text".
That silently loses it. `NoteEditorScreen`'s flush is guarded by
`bodyText != note.body`, so a draft handed the text already has nothing to
save — share a link, press back without typing, and it is gone. Which is
exactly the shape of a share: the common case is walking away.

So `captureShared` makes the row first and opens the editor on the real
note. A share has already said "keep this"; creating it is what honours
that, and back then leaves a saved note rather than a decision.

## launchMode="singleTop"

The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own intent,
which is why `onNewIntent` already worked there. A share intent is built by
the OTHER app and nothing here can add a flag to it, so the activity has to
declare it. Without that, every share while the app was running would stack a
second MainActivity — a second view model, a second board, and a back press
landing on a stale copy of the same app.

## Subject and text, both

A browser sends EXTRA_SUBJECT as the page title and EXTRA_TEXT as the URL.
Keeping both makes the note read as its title, because the core names a note
by its first line — the difference between a board you can scan and a column
of identical links. `distinct` because plenty of senders put the same string
in both.

The extras are removed on read, like the reminder's note id and for the same
reason: the activity keeps its launch intent, so without consuming them a
rotation would replay the share and mint the note again.

## Not included: images

`image/*` is deliberately absent from the filter. Nothing in this app can
create an attachment — the core has `delete_attachment` and no counterpart,
and the FFI exposes neither. Declaring the mime type would put ThoughtSync in
front of people in the share sheet for a job it cannot do, and fail after
they had already chosen it. Adding it needs an attachment-creation path
through the core, the FFI and sync, which is its own piece of work.

The desktop half of #1899 — a global hotkey — is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
2026-09-01 08:53:38 -04:00
co-authored by Claude Opus 5
parent cc50812a86
commit c8318c323a
4 changed files with 141 additions and 1 deletions
+38
View File
@@ -99,15 +99,53 @@
android:supportsRtl="true"
android:theme="@style/Theme.ThoughtSync"
android:usesCleartextTraffic="true">
<!--
launchMode="singleTop" exists for the SHARE filters below.
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
intent, so onNewIntent already worked for that one. A share intent is
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
and nothing here can add a flag to it. Without singleTop declared on the
activity itself, every share while the app is running would stack a
second MainActivity on top of the first: a second view model, a second
board, and a back press that lands on a stale copy of the same app.
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.ThoughtSync">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Capture without opening the app first: Share → ThoughtSync 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.
-->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!--
The label is what appears in the text-selection menu beside Copy and
Share, where "ThoughtSync" would say who rather than what.
-->
<intent-filter android:label="@string/capture_process_text">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!--
@@ -53,12 +53,24 @@ class MainActivity : ComponentActivity() {
*/
private val requestedNote = mutableStateOf<String?>(null)
/**
* Text 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
* composition was built, so a piece of state it is already reading is the only
* 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)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as ThoughtSyncApplication
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
setContent {
ThoughtSyncTheme {
@@ -69,7 +81,7 @@ class MainActivity : ComponentActivity() {
// than render an empty board that looks like data loss.
StoreUnavailableScreen(reason = app.openFailure)
} else {
App(core, requestedNote)
App(core, requestedNote, sharedText)
}
}
}
@@ -79,6 +91,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
requestedNote.value = takeRequestedNote(intent)
sharedText.value = takeSharedText(intent)
}
/**
@@ -94,6 +107,54 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
return id
}
/**
* Read the text 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 =
when (intent?.action) {
Intent.ACTION_SEND -> intent.takeSendText()
Intent.ACTION_PROCESS_TEXT -> intent.takeProcessText()
else -> null
}
return shared?.takeIf { it.isNotBlank() }
}
}
/**
* The shared text, with a subject line above it when the sender gave one.
*
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
* the core names a note by its first line — so this is not decoration, it is what
* turns a board of identical-looking links into a board you can scan.
*
* `distinct` because plenty of apps put the same string in both, and a note that
* says the URL twice is worse than one that says it once.
*/
private fun Intent.takeSendText(): String? {
val body = getStringExtra(Intent.EXTRA_TEXT)
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
removeExtra(Intent.EXTRA_TEXT)
removeExtra(Intent.EXTRA_SUBJECT)
return listOfNotNull(subject, body)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
.joinToString("\n")
}
/** The selection from another app's text field, via the selection toolbar. */
private fun Intent.takeProcessText(): String? {
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
removeExtra(Intent.EXTRA_PROCESS_TEXT)
return text
}
/** Which screen is up. Exactly one at a time. */
@@ -115,6 +176,7 @@ private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
private fun App(
core: ThoughtSync,
requestedNote: MutableState<String?>,
sharedText: MutableState<String?>,
) {
val context = LocalContext.current
val board: BoardViewModel =
@@ -137,6 +199,15 @@ 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
}
}
ReminderAlarms(core)
// A pull can rewrite every note the board is holding, so a sync that changed
// anything tells it to reload. Wired here, at the one place that owns both.
@@ -256,6 +256,32 @@ class BoardViewModel(
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
}
/**
* Capture text shared into the app from somewhere else, and open it.
*
* The note is CREATED here rather than opened as a pre-filled draft, and that
* is the whole design of this path. The editor only flushes when its text
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
* draft arriving already full of the shared text is a draft with nothing to
* save — share a link, press back without typing, and it would be gone. A
* share has already said "keep this"; making the row first is what honours it.
*
* Opening the editor afterwards is then free of that risk: the note exists,
* 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
// 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)
}
/**
* Set when a draft's editor closes, so a create still in flight does not reopen
* it. The editor flushes its text and then closes, and the flush is a coroutine —
@@ -66,6 +66,11 @@
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Quick capture from outside the app: the share sheet and the text-selection
toolbar. "New note" says what happens; the activity's own label would say
who it happens in. -->
<string name="capture_process_text">New note</string>
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
schema still says Label, and no string here needs to know that. -->
<string name="tags_manage">Manage tags</string>