mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: open the composer for mailto links sent with SENDTO
Most apps start an email with `ACTION_SENDTO` and a `mailto:` URI. The manifest offered the app for it, but React Native's Linking only forwards VIEW intents, so the app opened on the inbox instead of the composer. MainActivity now rewrites a `SENDTO mailto:` intent into the equivalent `VIEW mailto:` link before React Native sees it, on a cold start and when the running app receives it. The EXTRA_EMAIL, EXTRA_CC, EXTRA_BCC, EXTRA_SUBJECT and EXTRA_TEXT extras are folded into the link's query. Bcc recipients of a mailto link now reach the composer too; they were dropped for VIEW links as well.
This commit is contained in:
@@ -17,12 +17,14 @@ class MainActivity : ReactActivity() {
|
|||||||
// coloring the background, status bar, and navigation bar.
|
// coloring the background, status bar, and navigation bar.
|
||||||
// This is required for expo-splash-screen.
|
// This is required for expo-splash-screen.
|
||||||
setTheme(R.style.AppTheme);
|
setTheme(R.style.AppTheme);
|
||||||
|
ShareIntentStore.rewriteSendToAsView(intent)
|
||||||
super.onCreate(null)
|
super.onCreate(null)
|
||||||
NotificationTapStore.captureFromIntent(intent)
|
NotificationTapStore.captureFromIntent(intent)
|
||||||
ShareIntentStore.captureFromIntent(intent, contentResolver)
|
ShareIntentStore.captureFromIntent(intent, contentResolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
ShareIntentStore.rewriteSendToAsView(intent)
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
|
ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share ->
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import com.facebook.react.bridge.WritableMap
|
|||||||
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
|
// Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE
|
||||||
// intent (the system share sheet). MainActivity fills this on onCreate /
|
// intent (the system share sheet). MainActivity fills this on onCreate /
|
||||||
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
|
// onNewIntent; JS drains it via BulwarkFcm.getInitialShare or reacts to the
|
||||||
// live "app:share" event and opens the composer.
|
// live "app:share" event and opens the composer. `SENDTO mailto:` intents are
|
||||||
|
// rewritten into `VIEW mailto:` links instead (see rewriteSendToAsView).
|
||||||
object ShareIntentStore {
|
object ShareIntentStore {
|
||||||
@Volatile
|
@Volatile
|
||||||
private var pending: SharePayload? = null
|
private var pending: SharePayload? = null
|
||||||
@@ -56,6 +57,40 @@ object ShareIntentStore {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `SENDTO mailto:` is how most apps start an email, but React Native's
|
||||||
|
// Linking only forwards VIEW intents, so the app opened on the inbox.
|
||||||
|
// Turn it into the equivalent `VIEW mailto:` link, folding the recipient,
|
||||||
|
// subject and text extras into the URI, so it reaches JS like any other
|
||||||
|
// mailto link: Linking.getInitialURL on a cold start, the "url" event
|
||||||
|
// while running. Must run before the intent is handed to React Native.
|
||||||
|
fun rewriteSendToAsView(intent: Intent?) {
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
if (action != Intent.ACTION_SENDTO) return
|
||||||
|
val data = intent.data ?: return
|
||||||
|
if (!"mailto".equals(data.scheme, ignoreCase = true)) return
|
||||||
|
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
addressExtra(intent, Intent.EXTRA_EMAIL).forEach { params.add("to=${Uri.encode(it)}") }
|
||||||
|
addressExtra(intent, Intent.EXTRA_CC).forEach { params.add("cc=${Uri.encode(it)}") }
|
||||||
|
addressExtra(intent, Intent.EXTRA_BCC).forEach { params.add("bcc=${Uri.encode(it)}") }
|
||||||
|
intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.let {
|
||||||
|
params.add("subject=${Uri.encode(it.toString().take(512))}")
|
||||||
|
}
|
||||||
|
intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.let {
|
||||||
|
params.add("body=${Uri.encode(it.toString().take(MAX_TEXT))}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val link = data.toString()
|
||||||
|
val separator = if (link.contains('?')) "&" else "?"
|
||||||
|
intent.action = Intent.ACTION_VIEW
|
||||||
|
intent.data = if (params.isEmpty()) data else Uri.parse(link + separator + params.joinToString("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXTRA_EMAIL/CC/BCC are documented as String[], but some apps put a
|
||||||
|
// single String.
|
||||||
|
private fun addressExtra(intent: Intent, key: String): List<String> =
|
||||||
|
intent.getStringArrayExtra(key)?.filterNotNull() ?: listOfNotNull(intent.getStringExtra(key))
|
||||||
|
|
||||||
private class SharedFile(val name: String, val mimeType: String, val size: Double)
|
private class SharedFile(val name: String, val mimeType: String, val size: Double)
|
||||||
|
|
||||||
// Ask the provider for the file's real name, type and size: a MediaStore
|
// Ask the provider for the file's real name, type and size: a MediaStore
|
||||||
|
|||||||
@@ -30,12 +30,32 @@ describe('parseDeepLink', () => {
|
|||||||
kind: 'compose',
|
kind: 'compose',
|
||||||
to: [{ email: 'a@b.co' }],
|
to: [{ email: 'a@b.co' }],
|
||||||
cc: [{ email: 'c@d.co' }],
|
cc: [{ email: 'c@d.co' }],
|
||||||
|
bcc: [],
|
||||||
subject: 'Hi',
|
subject: 'Hi',
|
||||||
body: undefined,
|
body: undefined,
|
||||||
});
|
});
|
||||||
expect(parseDeepLink('mailto:nope')).toBeNull();
|
expect(parseDeepLink('mailto:nope')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads the link MainActivity builds from a SENDTO mailto: intent and its extras', () => {
|
||||||
|
// SENDTO mailto: + EXTRA_EMAIL/CC/BCC/SUBJECT/TEXT, each value Uri.encode()d.
|
||||||
|
expect(parseDeepLink(
|
||||||
|
'mailto:?to=bob%40partner.example&cc=carol%40partner.example&bcc=dave%40partner.example'
|
||||||
|
+ '&subject=Hi%20there&body=Line%201%0ALine%202',
|
||||||
|
)).toEqual({
|
||||||
|
kind: 'compose',
|
||||||
|
to: [{ email: 'bob@partner.example' }],
|
||||||
|
cc: [{ email: 'carol@partner.example' }],
|
||||||
|
bcc: [{ email: 'dave@partner.example' }],
|
||||||
|
subject: 'Hi there',
|
||||||
|
body: 'Line 1\nLine 2',
|
||||||
|
});
|
||||||
|
// An address in the URI itself plus EXTRA_EMAIL.
|
||||||
|
expect(parseDeepLink('mailto:bob@partner.example?to=eve%40partner.example')).toMatchObject({
|
||||||
|
to: [{ email: 'bob@partner.example' }, { email: 'eve@partner.example' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects unknown links', () => {
|
it('rejects unknown links', () => {
|
||||||
expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull();
|
expect(parseDeepLink('bulwarkmobile://whatever')).toBeNull();
|
||||||
expect(parseDeepLink('garbage')).toBeNull();
|
expect(parseDeepLink('garbage')).toBeNull();
|
||||||
@@ -94,6 +114,17 @@ describe('handleDeepLink', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes Bcc recipients through to the composer', async () => {
|
||||||
|
const navigation = nav();
|
||||||
|
await handleDeepLink(
|
||||||
|
{ kind: 'compose', to: [{ email: 'a@b.co' }], cc: [], bcc: [{ email: 'c@d.co' }] },
|
||||||
|
{ navigation: navigation as never, resolveThreadId: async () => null },
|
||||||
|
);
|
||||||
|
expect(navigation.navigate).toHaveBeenCalledWith('Compose', expect.objectContaining({
|
||||||
|
prefillBcc: [{ email: 'c@d.co' }],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('refuses when the linked account is not signed in', async () => {
|
it('refuses when the linked account is not signed in', async () => {
|
||||||
const navigation = nav();
|
const navigation = nav();
|
||||||
const ok = await handleDeepLink(
|
const ok = await handleDeepLink(
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type DeepLink =
|
|||||||
| { kind: 'contacts' }
|
| { kind: 'contacts' }
|
||||||
| { kind: 'files' }
|
| { kind: 'files' }
|
||||||
| { kind: 'settings'; tab?: string }
|
| { kind: 'settings'; tab?: string }
|
||||||
| { kind: 'compose'; to: EmailAddress[]; cc: EmailAddress[]; subject?: string; body?: string };
|
| { kind: 'compose'; to: EmailAddress[]; cc: EmailAddress[]; bcc?: EmailAddress[]; subject?: string; body?: string };
|
||||||
|
|
||||||
function decodeSegment(value: string): string {
|
function decodeSegment(value: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +65,7 @@ export function parseDeepLink(url: string): DeepLink | null {
|
|||||||
kind: 'compose',
|
kind: 'compose',
|
||||||
to: toAddresses(parsed.to),
|
to: toAddresses(parsed.to),
|
||||||
cc: toAddresses(parsed.cc),
|
cc: toAddresses(parsed.cc),
|
||||||
|
bcc: toAddresses(parsed.bcc),
|
||||||
subject: parsed.subject,
|
subject: parsed.subject,
|
||||||
body: parsed.body,
|
body: parsed.body,
|
||||||
};
|
};
|
||||||
@@ -168,6 +169,7 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr
|
|||||||
navigation.navigate('Compose', {
|
navigation.navigate('Compose', {
|
||||||
prefillTo: link.to,
|
prefillTo: link.to,
|
||||||
prefillCc: link.cc.length > 0 ? link.cc : undefined,
|
prefillCc: link.cc.length > 0 ? link.cc : undefined,
|
||||||
|
prefillBcc: link.bcc?.length ? link.bcc : undefined,
|
||||||
prefillSubject: link.subject,
|
prefillSubject: link.subject,
|
||||||
prefillBody: link.body,
|
prefillBody: link.body,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user