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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user