fix: accept Intent.EXTRA_EMAIL as String or String[] - #256
Conversation
Intent.EXTRA_EMAIL is documented as a String[] of e-mail addresses, but LaunchConversationActivity and ShareIntentActivity read it with getStringExtra(), which silently returns null for the array extras put by well-behaved senders (including this app's own VCardDetailEffectHandler, which already puts EXTRA_EMAIL as a String[]). Use getStringArrayExtra() so intents carrying the extra in its documented type are handled, and forward every address rather than only the first one. Fixes GrapheneOS#164
RankoR
left a comment
There was a problem hiding this comment.
Thanks for your contribution!
- Added/changed code should be covered by tests
- PR title states that both String and String[] now accepted, but in the code you're retrieving only array.
| } | ||
| final boolean haveAddress = !TextUtils.isEmpty(intent.getStringExtra(ADDRESS)); | ||
| final boolean haveEmail = !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL)); | ||
| final String[] emails = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); |
There was a problem hiding this comment.
Array items are not guaranteed to be non-null.
LaunchConversationActivity.java:113-119:
for (String recipient : recipients) {
if (recipient.length() < MAX_RECIPIENT_LENGTH) { // Will crash here
So, it should also be fixed in LaunchConversationActivity.java:113-119:
for (String recipient : recipients) {
if (!TextUtils.isEmpty(recipient) && recipient.length() < MAX_RECIPIENT_LENGTH) {
Same for ShareIntentActivity
There was a problem hiding this comment.
You're right, and it's worse than a latent risk — my change is what made it reachable. Before it, recipients was either split(",") output (never null elements) or a single getStringExtra guarded by !TextUtils.isEmpty. Reading the array put another app's contents straight into trimInvalidRecipients(), and since LaunchConversationActivity is android:exported="true" with BROWSABLE, any app (or an sms: link) can send EXTRA_EMAIL = new String[]{null}.
Reproduced it as a unit test against the old code, and it's exactly your prediction:
java.lang.NullPointerException: Cannot invoke "String.length()" because "recipient" is null
Applied your suggestion verbatim, which also fixes the pre-existing empty-string case ("a,,b".split(",") yields "", and "".length() < MAX was accepted as a recipient):
static String[] trimInvalidRecipients(String[] recipients) {
List<String> trimmedRecipients = new ArrayList<>();
for (String recipient : recipients) {
// The recipients come from another app's intent extras, so entries may be null or
// empty; TextUtils.isEmpty() is null-safe where recipient.length() is not.
if (!TextUtils.isEmpty(recipient) && recipient.length() < MAX_RECIPIENT_LENGTH) {
trimmedRecipients.add(recipient);
}
}I also stopped bad entries earlier so haveEmail doesn't become true for an array that holds nothing usable — an all-null array now reads as "no email" and falls through to the contact picker instead of producing a recipient list that trims to empty.
For ShareIntentActivity I handled it in the same spirit but without needing a null-unsafe call at all — see the reply on your other comment.
| intent.getStringExtra(Intent.EXTRA_EMAIL).isNullOrEmpty() | ||
| intent.getStringArrayExtra(Intent.EXTRA_EMAIL).isNullOrEmpty() | ||
|
|
||
| if (Intent.ACTION_SEND != intent.action || hasNoDestination) { |
There was a problem hiding this comment.
Perhaps should become Intent.ACTION_SEND != intent.action || hasNoDestination || intent.hasExtra(Intent.EXTRA_STREAM), otherwise for EXTRA_STREAM + EXTRA_EMAIL intents we're losing EXTRA_STREAM.
There was a problem hiding this comment.
Good catch — and again my change is what exposed it. Before, hasNoDestination used getStringExtra(Intent.EXTRA_EMAIL), which returns null when the extra is a String[]. So for the very case this PR exists to support, the redirect didn't fire and the attachment survived by accident. Switching to getStringArrayExtra made the redirect fire, and LaunchConversationActivity only ever reads ADDRESS, EXTRA_EMAIL, SMS_BODY, the data-URI body and EXTRA_TEXT — never EXTRA_STREAM — so putExtras(intent) carries the stream along and then it's ignored.
Took your suggestion, but pulled the decision into a testable function rather than growing the condition, since it now has three parts and I wanted to cover it:
internal fun shouldRedirectToSendTo(intent: Intent): Boolean {
if (Intent.ACTION_SEND != intent.action) {
return false
}
if (intent.hasExtra(Intent.EXTRA_STREAM)) {
return false
}
val hasDestination = !intent.getStringExtra(EXTRA_ADDRESS).isNullOrEmpty() ||
hasEmailDestination(intent)
return hasDestination
}
internal fun hasEmailDestination(intent: Intent): Boolean {
val emails = intent.getStringArrayExtra(Intent.EXTRA_EMAIL)
if (emails != null) {
return emails.any { !it.isNullOrEmpty() }
}
return !intent.getStringExtra(Intent.EXTRA_EMAIL).isNullOrEmpty()
}redirectToSendToIfNeeded() is now just if (!shouldRedirectToSendTo(intent)) return false. hasEmailDestination also covers the null-entry problem from your other comment here — an arrayOf(null, "") destination reads as absent rather than triggering a redirect to a recipient list that can't be built.
Verified with unit tests: EXTRA_STREAM + EXTRA_EMAIL and EXTRA_STREAM + address both stay in the picker; email-as-array, email-as-String and address alone all still redirect. Both EXTRA_STREAM tests fail against the previous code.
- Read Intent.EXTRA_EMAIL as either String[] or String again. The previous
commit replaced the String read with a String[] read, so senders that
put a single String regressed even though the PR title claimed both.
- trimInvalidRecipients() now uses TextUtils.isEmpty() instead of
recipient.length(). LaunchConversationActivity is exported, so the
array contents come from another app and may contain nulls; a
String[]{null} extra crashed with an NPE.
- ShareIntentActivity no longer redirects an ACTION_SEND intent that
carries EXTRA_STREAM. LaunchConversationActivity only understands a
destination plus a text body, so redirecting dropped the attachment.
- Add unit tests for both, covering the String/String[] duality, null and
empty entries, over-long recipients, and the EXTRA_STREAM case.
|
Thanks @RankoR — all four points addressed, and both of your inline findings were regressions I introduced rather than pre-existing ones, which I've written up on the respective threads. 2. Title vs. code. You're right: the commit replaced the static String[] getEmailRecipients(final Intent intent) {
final String[] emails = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
if (emails != null) {
final List<String> nonEmpty = new ArrayList<>(emails.length);
for (final String email : emails) {
if (!TextUtils.isEmpty(email)) {
nonEmpty.add(email);
}
}
return nonEmpty.isEmpty() ? null : nonEmpty.toArray(new String[0]);
}
final String email = intent.getStringExtra(Intent.EXTRA_EMAIL);
return TextUtils.isEmpty(email) ? null : new String[] { email };
}The two forms can't collide: 1. Test coverage. Added 19 unit tests in
All 19 pass on the change. Against the previous code, 10 of the 19 fail, so they pin the reported behaviour rather than just describing it: Run with The |
|
Pushed as Ready for another look. As noted above, the |
detekt's ReturnCount rule caps a function at 2 returns; the new shouldRedirectToSendTo had 3. Express it as a single boolean instead, which also reads closer to the condition suggested in review, and collapse hasEmailDestination onto one return while there.
Handle EXTRA_EMAIL when callers pass a single String instead of String[].
Fixes #164
Test plan