From aa882db1d8160c33b578c371f9535637349fee2e Mon Sep 17 00:00:00 2001 From: Daniel Hopkins Date: Thu, 13 Aug 2026 19:43:35 -0700 Subject: [PATCH 1/4] fix(android): don't drop the notification when a messaging style person times out getMessagingStyleTask awaits each person future with a 20s timeout it never catches -- the only image-loading path in this file that doesn't. When the deadline expires the TimeoutException escapes the callable, NotificationManager awaits the style with a bare get(), and displayNotification rejects, so nothing is posted at all. getPerson already bounds its own icon fetch at 10s and degrades to an icon-less Person, so this outer deadline expires only when the process was frozen mid-fetch (Android's cached-app freezer, common on 15/16 for headless FCM handlers). Guava reports it as "Waited 20 seconds (plus 200 seconds ... delay)" -- the waiting thread descheduled long past its deadline. Catch it and degrade the same way the BigPicture, LargeIcon and person-icon paths already do: fall back to the person built from everything in the bundle except the remotely fetched icon, so the sender keeps their name, key and uri and only the avatar is lost. --- .../model/NotificationAndroidStyleModel.java | 75 ++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java b/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java index 9afa6d65..04d6cff8 100644 --- a/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java +++ b/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java @@ -34,6 +34,7 @@ import com.google.common.util.concurrent.ListeningExecutorService; import java.util.ArrayList; import java.util.Objects; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -50,6 +51,31 @@ public static NotificationAndroidStyleModel fromBundle(Bundle styleBundle) { return new NotificationAndroidStyleModel(styleBundle); } + /** Builds a person from everything in its bundle except the remotely fetched icon. */ + private static Person.Builder getPersonBuilder(Bundle personBundle) { + Person.Builder personBuilder = new Person.Builder(); + + personBuilder.setName(personBundle.getString("name")); + + if (personBundle.containsKey("id")) { + personBuilder.setKey(personBundle.getString("id")); + } + + if (personBundle.containsKey("bot")) { + personBuilder.setBot(personBundle.getBoolean("bot")); + } + + if (personBundle.containsKey("important")) { + personBuilder.setImportant(personBundle.getBoolean("important")); + } + + if (personBundle.containsKey("uri")) { + personBuilder.setUri(personBundle.getString("uri")); + } + + return personBuilder; + } + /** * Converts a person bundle from JS into a Person * @@ -60,21 +86,7 @@ private static ListenableFuture getPerson( ListeningExecutorService lExecutor, Bundle personBundle) { return lExecutor.submit( () -> { - Person.Builder personBuilder = new Person.Builder(); - - personBuilder.setName(personBundle.getString("name")); - - if (personBundle.containsKey("id")) { - personBuilder.setKey(personBundle.getString("id")); - } - - if (personBundle.containsKey("bot")) { - personBuilder.setBot(personBundle.getBoolean("bot")); - } - - if (personBundle.containsKey("important")) { - personBuilder.setImportant(personBundle.getBoolean("important")); - } + Person.Builder personBuilder = getPersonBuilder(personBundle); if (personBundle.containsKey("icon")) { String personIcon = Objects.requireNonNull(personBundle.getString("icon")); @@ -100,14 +112,27 @@ private static ListenableFuture getPerson( } } - if (personBundle.containsKey("uri")) { - personBuilder.setUri(personBundle.getString("uri")); - } - return personBuilder.build(); }); } + /** + * Awaits a person, degrading to an icon-less one if it does not arrive in time. + * + *

getPerson() already bounds its own icon fetch, so this deadline expires only when the + * process was frozen mid-fetch. Losing the person there costs an avatar; letting the + * TimeoutException escape costs the entire notification. + */ + private static Person awaitPerson(ListenableFuture personTask, Bundle personBundle) + throws ExecutionException, InterruptedException { + try { + return personTask.get(20, TimeUnit.SECONDS); + } catch (TimeoutException e) { + Logger.e(TAG, "Timeout occurred whilst trying to retrieve a messaging style person", e); + return getPersonBuilder(personBundle).build(); + } + } + public Bundle toBundle() { return (Bundle) mNotificationAndroidStyleBundle.clone(); } @@ -291,11 +316,9 @@ private ListenableFuture getMessagingStyleTask( ListeningExecutorService lExecutor) { return lExecutor.submit( () -> { - Person person = - getPerson( - lExecutor, - Objects.requireNonNull(mNotificationAndroidStyleBundle.getBundle("person"))) - .get(20, TimeUnit.SECONDS); + Bundle personBundle = + Objects.requireNonNull(mNotificationAndroidStyleBundle.getBundle("person")); + Person person = awaitPerson(getPerson(lExecutor, personBundle), personBundle); NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(person); @@ -322,9 +345,9 @@ private ListenableFuture getMessagingStyleTask( long timestamp = BundleValueReader.getLongPreserving(message, "timestamp"); if (message.containsKey("person")) { + Bundle messagePersonBundle = Objects.requireNonNull(message.getBundle("person")); messagePerson = - getPerson(lExecutor, Objects.requireNonNull(message.getBundle("person"))) - .get(20, TimeUnit.SECONDS); + awaitPerson(getPerson(lExecutor, messagePersonBundle), messagePersonBundle); } messagingStyle = From f7b950117f0fa5603a8fe1a102c3cea711ccfd3f Mon Sep 17 00:00:00 2001 From: Daniel Hopkins Date: Thu, 13 Aug 2026 23:32:42 -0700 Subject: [PATCH 2/4] test(android): cover messaging style person timeouts Injects an executor whose person futures always time out on their timed get, which is what the caller observes when the process was frozen mid-fetch. A slow network cannot reach this path -- getPerson bounds its own icon fetch at 10s and degrades -- so the stub is the only way to exercise it without a real freeze, and it keeps the test instant rather than burning the 20s deadline. Three of the four cases fail against the unpatched getMessagingStyleTask; the fourth guards the builder now shared by the normal and timed-out paths. --- ...ionAndroidStyleModelPersonTimeoutTest.java | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java diff --git a/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java b/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java new file mode 100644 index 00000000..72f8b9c6 --- /dev/null +++ b/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java @@ -0,0 +1,199 @@ +package app.notifee.core.model; + +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.os.Bundle; +import androidx.core.app.NotificationCompat; +import androidx.core.app.Person; +import com.google.common.util.concurrent.ForwardingListeningExecutorService; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +/** + * Regression tests for the messaging style's person lookups. + * + *

{@code getMessagingStyleTask} awaits each person with a timed {@code get()}. That deadline is + * unreachable through a slow network — {@code getPerson} bounds its own icon fetch and degrades to + * an icon-less person — so it expires only when the process stopped being scheduled mid-fetch, e.g. + * under Android's cached-app freezer. Letting the resulting TimeoutException escape the callable + * fails the whole notification, so the user loses the message over an avatar. + * + *

{@link TimingOutPersonExecutor} simulates that by handing back a person future whose timed get + * always times out. + */ +@RunWith(RobolectricTestRunner.class) +public class NotificationAndroidStyleModelPersonTimeoutTest { + + private static final int STYLE_TYPE_MESSAGING = 3; + + @Test + public void messagingStyle_personTimesOut_stillBuildsTheStyle() throws Exception { + NotificationAndroidStyleModel model = + NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); + + NotificationCompat.Style style = model.getStyleTask(new TimingOutPersonExecutor()).get(); + + assertTrue(style instanceof NotificationCompat.MessagingStyle); + } + + @Test + public void messagingStyle_personTimesOut_keepsEverythingButTheIcon() throws Exception { + NotificationAndroidStyleModel model = + NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); + + NotificationCompat.MessagingStyle style = + (NotificationCompat.MessagingStyle) model.getStyleTask(new TimingOutPersonExecutor()).get(); + + Person user = style.getUser(); + assertEquals("Me", user.getName()); + assertEquals("viewer-1", user.getKey()); + assertEquals("mailto:me@example.com", user.getUri()); + assertNull(user.getIcon()); + } + + /** A dropped sender name would cost message attribution, a worse loss than the avatar. */ + @Test + public void messagingStyle_messagePersonTimesOut_keepsTheSenderName() throws Exception { + NotificationAndroidStyleModel model = + NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); + + NotificationCompat.MessagingStyle style = + (NotificationCompat.MessagingStyle) model.getStyleTask(new TimingOutPersonExecutor()).get(); + + List messages = style.getMessages(); + assertEquals(1, messages.size()); + assertEquals("hello", messages.get(0).getText().toString()); + assertEquals("Alice", messages.get(0).getPerson().getName()); + assertNull(messages.get(0).getPerson().getIcon()); + } + + /** Guards the builder shared by the normal and timed-out paths. */ + @Test + public void messagingStyle_personResolves_mapsEveryBundleField() throws Exception { + NotificationAndroidStyleModel model = + NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); + + NotificationCompat.MessagingStyle style = + (NotificationCompat.MessagingStyle) + model.getStyleTask(MoreExecutors.newDirectExecutorService()).get(); + + Person user = style.getUser(); + assertEquals("Me", user.getName()); + assertEquals("viewer-1", user.getKey()); + assertEquals("mailto:me@example.com", user.getUri()); + assertTrue(user.isImportant()); + assertEquals("Room", style.getConversationTitle().toString()); + assertEquals("Alice", style.getMessages().get(0).getPerson().getName()); + } + + private static Bundle messagingStyleBundle() { + Bundle message = new Bundle(); + message.putString("text", "hello"); + message.putLong("timestamp", 1_700_000_000_000L); + message.putBundle("person", personBundle("Alice", "alice-1")); + + ArrayList messages = new ArrayList<>(); + messages.add(message); + + Bundle styleBundle = new Bundle(); + styleBundle.putInt("type", STYLE_TYPE_MESSAGING); + styleBundle.putString("title", "Room"); + styleBundle.putBoolean("group", true); + styleBundle.putBundle("person", personBundle("Me", "viewer-1")); + styleBundle.putParcelableArrayList("messages", messages); + return styleBundle; + } + + /** No "icon" key, so the resolving path does no image fetch either. */ + private static Bundle personBundle(String name, String id) { + Bundle person = new Bundle(); + person.putString("name", name); + person.putString("id", id); + person.putBoolean("important", true); + person.putString("uri", name.equals("Me") ? "mailto:me@example.com" : "mailto:a@example.com"); + return person; + } + + /** + * Runs the style task inline, but every person submitted from inside it comes back as a future + * whose timed get times out — what the caller observes when the process was frozen mid-fetch. + */ + private static final class TimingOutPersonExecutor extends ForwardingListeningExecutorService { + private final ListeningExecutorService delegate = MoreExecutors.newDirectExecutorService(); + private boolean styleTaskSubmitted = false; + + @Override + protected ListeningExecutorService delegate() { + return delegate; + } + + @Override + public ListenableFuture submit(Callable task) { + if (styleTaskSubmitted) { + return timesOut(); + } + styleTaskSubmitted = true; + return delegate.submit(task); + } + + private static ListenableFuture timesOut() { + return new ListenableFuture() { + @Override + public void addListener(Runnable listener, Executor executor) {} + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public boolean isCancelled() { + return false; + } + + @Override + public boolean isDone() { + return false; + } + + @Override + public T get() { + throw new UnsupportedOperationException("the style awaits persons with a timeout"); + } + + @Override + public T get(long timeout, TimeUnit unit) throws TimeoutException { + throw new TimeoutException("simulated frozen process"); + } + }; + } + } +} From 8134cd12b89a54b7be9b0485024c651a379e0998 Mon Sep 17 00:00:00 2001 From: Daniel Hopkins Date: Thu, 13 Aug 2026 23:46:49 -0700 Subject: [PATCH 3/4] test(android): assert the degraded person keeps bot and important mapsEveryBundleField skipped getPersonBuilder's bot branch, and the timeout cases only checked name/key/uri -- so nothing proved the fallback applies the full builder rather than just the name. --- .../NotificationAndroidStyleModelPersonTimeoutTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java b/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java index 72f8b9c6..ef47dfb7 100644 --- a/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java +++ b/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java @@ -76,6 +76,8 @@ public void messagingStyle_personTimesOut_keepsEverythingButTheIcon() throws Exc assertEquals("Me", user.getName()); assertEquals("viewer-1", user.getKey()); assertEquals("mailto:me@example.com", user.getUri()); + assertTrue(user.isImportant()); + assertTrue(user.isBot()); assertNull(user.getIcon()); } @@ -110,7 +112,9 @@ public void messagingStyle_personResolves_mapsEveryBundleField() throws Exceptio assertEquals("viewer-1", user.getKey()); assertEquals("mailto:me@example.com", user.getUri()); assertTrue(user.isImportant()); + assertTrue(user.isBot()); assertEquals("Room", style.getConversationTitle().toString()); + assertTrue(style.isGroupConversation()); assertEquals("Alice", style.getMessages().get(0).getPerson().getName()); } @@ -138,6 +142,7 @@ private static Bundle personBundle(String name, String id) { person.putString("name", name); person.putString("id", id); person.putBoolean("important", true); + person.putBoolean("bot", true); person.putString("uri", name.equals("Me") ? "mailto:me@example.com" : "mailto:a@example.com"); return person; } From e662a48feacbb6626226b9199060a6fa55b05314 Mon Sep 17 00:00:00 2001 From: Daniel Hopkins Date: Fri, 14 Aug 2026 01:13:28 -0700 Subject: [PATCH 4/4] fix(android): degrade the person on any failed lookup, not just timeouts The icon decode sits outside getPerson()'s try block, so the lookup can fail outright as well as time out. Both cost an avatar; both were costing the whole notification. InterruptedException still propagates: it means the thread is being torn down, not that the person is unavailable. --- .../model/NotificationAndroidStyleModel.java | 18 ++-- ...onAndroidStyleModelPersonFailureTest.java} | 93 +++++++++++++++---- 2 files changed, 87 insertions(+), 24 deletions(-) rename packages/react-native/android/src/test/java/app/notifee/core/model/{NotificationAndroidStyleModelPersonTimeoutTest.java => NotificationAndroidStyleModelPersonFailureTest.java} (66%) diff --git a/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java b/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java index 04d6cff8..07396d40 100644 --- a/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java +++ b/packages/react-native/android/src/main/java/app/notifee/core/model/NotificationAndroidStyleModel.java @@ -117,20 +117,26 @@ private static ListenableFuture getPerson( } /** - * Awaits a person, degrading to an icon-less one if it does not arrive in time. + * Awaits a person, degrading to an icon-less one if it cannot be delivered. * - *

getPerson() already bounds its own icon fetch, so this deadline expires only when the - * process was frozen mid-fetch. Losing the person there costs an avatar; letting the - * TimeoutException escape costs the entire notification. + *

getPerson() already bounds its own icon fetch, so the deadline expires only when the process + * was frozen mid-fetch, and it can still fail outright on the icon decode. Either way the loss is + * an avatar; letting the exception escape loses the entire notification. + * + *

InterruptedException deliberately propagates: it means this thread is being torn down, not + * that the person is unavailable. */ private static Person awaitPerson(ListenableFuture personTask, Bundle personBundle) - throws ExecutionException, InterruptedException { + throws InterruptedException { try { return personTask.get(20, TimeUnit.SECONDS); } catch (TimeoutException e) { Logger.e(TAG, "Timeout occurred whilst trying to retrieve a messaging style person", e); - return getPersonBuilder(personBundle).build(); + } catch (ExecutionException e) { + Logger.e(TAG, "An error occurred whilst trying to retrieve a messaging style person", e); } + + return getPersonBuilder(personBundle).build(); } public Bundle toBundle() { diff --git a/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java b/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonFailureTest.java similarity index 66% rename from packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java rename to packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonFailureTest.java index ef47dfb7..db2b5975 100644 --- a/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonTimeoutTest.java +++ b/packages/react-native/android/src/test/java/app/notifee/core/model/NotificationAndroidStyleModelPersonFailureTest.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -40,17 +41,19 @@ /** * Regression tests for the messaging style's person lookups. * - *

{@code getMessagingStyleTask} awaits each person with a timed {@code get()}. That deadline is - * unreachable through a slow network — {@code getPerson} bounds its own icon fetch and degrades to - * an icon-less person — so it expires only when the process stopped being scheduled mid-fetch, e.g. - * under Android's cached-app freezer. Letting the resulting TimeoutException escape the callable - * fails the whole notification, so the user loses the message over an avatar. + *

{@code getMessagingStyleTask} awaits each person with a timed {@code get()}, which can fail + * two ways. The deadline is unreachable through a slow network — {@code getPerson} bounds its own + * icon fetch and degrades to an icon-less person — so it expires only when the process stopped + * being scheduled mid-fetch, e.g. under Android's cached-app freezer. An ExecutionException means + * the lookup itself threw, which the icon decode outside {@code getPerson}'s try block can still + * do. Letting either escape the callable fails the whole notification, so the user loses the + * message over an avatar. * - *

{@link TimingOutPersonExecutor} simulates that by handing back a person future whose timed get - * always times out. + *

{@link FailingPersonExecutor} simulates both by handing back a person future that fails the + * way the caller would observe. */ @RunWith(RobolectricTestRunner.class) -public class NotificationAndroidStyleModelPersonTimeoutTest { +public class NotificationAndroidStyleModelPersonFailureTest { private static final int STYLE_TYPE_MESSAGING = 3; @@ -59,7 +62,7 @@ public void messagingStyle_personTimesOut_stillBuildsTheStyle() throws Exception NotificationAndroidStyleModel model = NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); - NotificationCompat.Style style = model.getStyleTask(new TimingOutPersonExecutor()).get(); + NotificationCompat.Style style = model.getStyleTask(timesOut()).get(); assertTrue(style instanceof NotificationCompat.MessagingStyle); } @@ -70,7 +73,7 @@ public void messagingStyle_personTimesOut_keepsEverythingButTheIcon() throws Exc NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); NotificationCompat.MessagingStyle style = - (NotificationCompat.MessagingStyle) model.getStyleTask(new TimingOutPersonExecutor()).get(); + (NotificationCompat.MessagingStyle) model.getStyleTask(timesOut()).get(); Person user = style.getUser(); assertEquals("Me", user.getName()); @@ -88,7 +91,7 @@ public void messagingStyle_messagePersonTimesOut_keepsTheSenderName() throws Exc NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); NotificationCompat.MessagingStyle style = - (NotificationCompat.MessagingStyle) model.getStyleTask(new TimingOutPersonExecutor()).get(); + (NotificationCompat.MessagingStyle) model.getStyleTask(timesOut()).get(); List messages = style.getMessages(); assertEquals(1, messages.size()); @@ -97,7 +100,35 @@ public void messagingStyle_messagePersonTimesOut_keepsTheSenderName() throws Exc assertNull(messages.get(0).getPerson().getIcon()); } - /** Guards the builder shared by the normal and timed-out paths. */ + @Test + public void messagingStyle_personLookupThrows_stillBuildsTheStyle() throws Exception { + NotificationAndroidStyleModel model = + NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); + + NotificationCompat.Style style = model.getStyleTask(throwsFrom()).get(); + + assertTrue(style instanceof NotificationCompat.MessagingStyle); + } + + @Test + public void messagingStyle_personLookupThrows_keepsEverythingButTheIcon() throws Exception { + NotificationAndroidStyleModel model = + NotificationAndroidStyleModel.fromBundle(messagingStyleBundle()); + + NotificationCompat.MessagingStyle style = + (NotificationCompat.MessagingStyle) model.getStyleTask(throwsFrom()).get(); + + Person user = style.getUser(); + assertEquals("Me", user.getName()); + assertEquals("viewer-1", user.getKey()); + assertEquals("mailto:me@example.com", user.getUri()); + assertTrue(user.isImportant()); + assertTrue(user.isBot()); + assertNull(user.getIcon()); + assertEquals("Alice", style.getMessages().get(0).getPerson().getName()); + } + + /** Guards the builder shared by the normal and degraded paths. */ @Test public void messagingStyle_personResolves_mapsEveryBundleField() throws Exception { NotificationAndroidStyleModel model = @@ -147,14 +178,39 @@ private static Bundle personBundle(String name, String id) { return person; } + /** How an awaited person future fails. */ + private interface PersonFailure { + void raise() throws ExecutionException, TimeoutException; + } + + private static FailingPersonExecutor timesOut() { + return new FailingPersonExecutor( + () -> { + throw new TimeoutException("simulated frozen process"); + }); + } + + private static FailingPersonExecutor throwsFrom() { + return new FailingPersonExecutor( + () -> { + throw new ExecutionException( + new IllegalStateException("Can't create an Icon from a recycled bitmap")); + }); + } + /** * Runs the style task inline, but every person submitted from inside it comes back as a future - * whose timed get times out — what the caller observes when the process was frozen mid-fetch. + * that fails the given way when awaited. */ - private static final class TimingOutPersonExecutor extends ForwardingListeningExecutorService { + private static final class FailingPersonExecutor extends ForwardingListeningExecutorService { private final ListeningExecutorService delegate = MoreExecutors.newDirectExecutorService(); + private final PersonFailure failure; private boolean styleTaskSubmitted = false; + FailingPersonExecutor(PersonFailure failure) { + this.failure = failure; + } + @Override protected ListeningExecutorService delegate() { return delegate; @@ -163,13 +219,13 @@ protected ListeningExecutorService delegate() { @Override public ListenableFuture submit(Callable task) { if (styleTaskSubmitted) { - return timesOut(); + return failingFuture(); } styleTaskSubmitted = true; return delegate.submit(task); } - private static ListenableFuture timesOut() { + private ListenableFuture failingFuture() { return new ListenableFuture() { @Override public void addListener(Runnable listener, Executor executor) {} @@ -195,8 +251,9 @@ public T get() { } @Override - public T get(long timeout, TimeUnit unit) throws TimeoutException { - throw new TimeoutException("simulated frozen process"); + public T get(long timeout, TimeUnit unit) throws ExecutionException, TimeoutException { + failure.raise(); + throw new AssertionError("unreachable"); } }; }