diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt index 8f6f69fb..daf2db28 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/MainActivity.kt @@ -17,12 +17,14 @@ class MainActivity : ReactActivity() { // coloring the background, status bar, and navigation bar. // This is required for expo-splash-screen. setTheme(R.style.AppTheme); + ShareIntentStore.rewriteSendToAsView(intent) super.onCreate(null) NotificationTapStore.captureFromIntent(intent) ShareIntentStore.captureFromIntent(intent, contentResolver) } override fun onNewIntent(intent: Intent) { + ShareIntentStore.rewriteSendToAsView(intent) super.onNewIntent(intent) setIntent(intent) ShareIntentStore.captureFromIntent(intent, contentResolver)?.let { share -> diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt index c5e5ed08..aee9dfb4 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/ShareIntentStore.kt @@ -12,7 +12,8 @@ import com.facebook.react.bridge.WritableMap // Process-wide holder for the most recent ACTION_SEND / ACTION_SEND_MULTIPLE // intent (the system share sheet). MainActivity fills this on onCreate / // 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 { @Volatile private var pending: SharePayload? = null @@ -56,6 +57,40 @@ object ShareIntentStore { 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() + 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 = + intent.getStringArrayExtra(key)?.filterNotNull() ?: listOfNotNull(intent.getStringExtra(key)) + 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 diff --git a/src/navigation/__tests__/linking.test.ts b/src/navigation/__tests__/linking.test.ts index 2b71fd7c..455d0daf 100644 --- a/src/navigation/__tests__/linking.test.ts +++ b/src/navigation/__tests__/linking.test.ts @@ -30,12 +30,32 @@ describe('parseDeepLink', () => { kind: 'compose', to: [{ email: 'a@b.co' }], cc: [{ email: 'c@d.co' }], + bcc: [], subject: 'Hi', body: undefined, }); 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', () => { expect(parseDeepLink('bulwarkmobile://whatever')).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 () => { const navigation = nav(); const ok = await handleDeepLink( diff --git a/src/navigation/linking.ts b/src/navigation/linking.ts index 14488c01..98e45602 100644 --- a/src/navigation/linking.ts +++ b/src/navigation/linking.ts @@ -19,7 +19,7 @@ export type DeepLink = | { kind: 'contacts' } | { kind: 'files' } | { 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 { try { @@ -65,6 +65,7 @@ export function parseDeepLink(url: string): DeepLink | null { kind: 'compose', to: toAddresses(parsed.to), cc: toAddresses(parsed.cc), + bcc: toAddresses(parsed.bcc), subject: parsed.subject, body: parsed.body, }; @@ -168,6 +169,7 @@ export async function handleDeepLink(link: DeepLink, nav: DeepLinkNavigator): Pr navigation.navigate('Compose', { prefillTo: link.to, prefillCc: link.cc.length > 0 ? link.cc : undefined, + prefillBcc: link.bcc?.length ? link.bcc : undefined, prefillSubject: link.subject, prefillBody: link.body, });