diff --git a/core/java/android/os/BundleMerger.java b/core/java/android/os/BundleMerger.java new file mode 100644 index 0000000000000..51bd4ea75005c --- /dev/null +++ b/core/java/android/os/BundleMerger.java @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file 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. + */ + +package android.os; + +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.util.Log; + +import com.android.internal.annotations.VisibleForTesting; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Objects; +import java.util.function.BinaryOperator; + +/** + * Configured rules for merging two {@link Bundle} instances. + *

+ * By default, values from both {@link Bundle} instances are blended together on + * a key-wise basis, and conflicting value definitions for a key are dropped. + *

+ * Nuanced strategies for handling conflicting value definitions can be applied + * using {@link #setMergeStrategy(String, int)} and + * {@link #setDefaultMergeStrategy(int)}. + *

+ * When conflicting values have inconsistent data types (such as trying + * to merge a {@link String} and a {@link Integer}), both conflicting values are + * rejected and the key becomes undefined, regardless of the requested strategy. + * + * @hide + */ +public class BundleMerger implements Parcelable { + private static final String TAG = "BundleMerger"; + + private @Strategy int mDefaultStrategy = STRATEGY_REJECT; + + private final ArrayMap mStrategies = new ArrayMap<>(); + + /** + * Merge strategy that rejects both conflicting values. + */ + public static final int STRATEGY_REJECT = 0; + + /** + * Merge strategy that selects the first of conflicting values. + */ + public static final int STRATEGY_FIRST = 1; + + /** + * Merge strategy that selects the last of conflicting values. + */ + public static final int STRATEGY_LAST = 2; + + /** + * Merge strategy that selects the "minimum" of conflicting values which are + * {@link Comparable} with each other. + */ + public static final int STRATEGY_COMPARABLE_MIN = 3; + + /** + * Merge strategy that selects the "maximum" of conflicting values which are + * {@link Comparable} with each other. + */ + public static final int STRATEGY_COMPARABLE_MAX = 4; + + /** + * Merge strategy that numerically adds both conflicting values. + */ + public static final int STRATEGY_NUMBER_ADD = 5; + + /** + * Merge strategy that numerically increments the first conflicting value by + * {@code 1} and ignores the last conflicting value. + */ + public static final int STRATEGY_NUMBER_INCREMENT_FIRST = 6; + + /** + * Merge strategy that combines conflicting values using a boolean "and" + * operation. + */ + public static final int STRATEGY_BOOLEAN_AND = 7; + + /** + * Merge strategy that combines conflicting values using a boolean "or" + * operation. + */ + public static final int STRATEGY_BOOLEAN_OR = 8; + + /** + * Merge strategy that combines two conflicting array values by appending + * the last array after the first array. + */ + public static final int STRATEGY_ARRAY_APPEND = 9; + + /** + * Merge strategy that combines two conflicting {@link ArrayList} values by + * appending the last {@link ArrayList} after the first {@link ArrayList}. + */ + public static final int STRATEGY_ARRAY_LIST_APPEND = 10; + + @IntDef(flag = false, prefix = { "STRATEGY_" }, value = { + STRATEGY_REJECT, + STRATEGY_FIRST, + STRATEGY_LAST, + STRATEGY_COMPARABLE_MIN, + STRATEGY_COMPARABLE_MAX, + STRATEGY_NUMBER_ADD, + STRATEGY_NUMBER_INCREMENT_FIRST, + STRATEGY_BOOLEAN_AND, + STRATEGY_BOOLEAN_OR, + STRATEGY_ARRAY_APPEND, + STRATEGY_ARRAY_LIST_APPEND, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface Strategy {} + + /** + * Create a empty set of rules for merging two {@link Bundle} instances. + */ + public BundleMerger() { + } + + private BundleMerger(@NonNull Parcel in) { + mDefaultStrategy = in.readInt(); + final int N = in.readInt(); + for (int i = 0; i < N; i++) { + mStrategies.put(in.readString(), in.readInt()); + } + } + + @Override + public void writeToParcel(@NonNull Parcel out, int flags) { + out.writeInt(mDefaultStrategy); + final int N = mStrategies.size(); + out.writeInt(N); + for (int i = 0; i < N; i++) { + out.writeString(mStrategies.keyAt(i)); + out.writeInt(mStrategies.valueAt(i)); + } + } + + @Override + public int describeContents() { + return 0; + } + + /** + * Configure the default merge strategy to be used when there isn't a + * more-specific strategy defined for a particular key via + * {@link #setMergeStrategy(String, int)}. + */ + public void setDefaultMergeStrategy(@Strategy int strategy) { + mDefaultStrategy = strategy; + } + + /** + * Configure the merge strategy to be used for the given key. + *

+ * Subsequent calls for the same key will overwrite any previously + * configured strategy. + */ + public void setMergeStrategy(@NonNull String key, @Strategy int strategy) { + mStrategies.put(key, strategy); + } + + /** + * Return the merge strategy to be used for the given key, as defined by + * {@link #setMergeStrategy(String, int)}. + *

+ * If no specific strategy has been configured for the given key, this + * returns {@link #setDefaultMergeStrategy(int)}. + */ + public @Strategy int getMergeStrategy(@NonNull String key) { + return (int) mStrategies.getOrDefault(key, mDefaultStrategy); + } + + /** + * Return a {@link BinaryOperator} which applies the strategies configured + * in this object to merge the two given {@link Bundle} arguments. + */ + public BinaryOperator asBinaryOperator() { + return this::merge; + } + + /** + * Apply the strategies configured in this object to merge the two given + * {@link Bundle} arguments. + * + * @return the merged {@link Bundle} result. If one argument is {@code null} + * it will return the other argument. If both arguments are null it + * will return {@code null}. + */ + @SuppressWarnings("deprecation") + public @Nullable Bundle merge(@Nullable Bundle first, @Nullable Bundle last) { + if (first == null && last == null) { + return null; + } + if (first == null) { + first = Bundle.EMPTY; + } + if (last == null) { + last = Bundle.EMPTY; + } + + // Start by bulk-copying all values without attempting to unpack any + // custom parcelables; we'll circle back to handle conflicts below + final Bundle res = new Bundle(); + res.putAll(first); + res.putAll(last); + + final ArraySet conflictingKeys = new ArraySet<>(); + conflictingKeys.addAll(first.keySet()); + conflictingKeys.retainAll(last.keySet()); + for (int i = 0; i < conflictingKeys.size(); i++) { + final String key = conflictingKeys.valueAt(i); + final int strategy = getMergeStrategy(key); + final Object firstValue = first.get(key); + final Object lastValue = last.get(key); + try { + res.putObject(key, merge(strategy, firstValue, lastValue)); + } catch (Exception e) { + Log.w(TAG, "Failed to merge key " + key + " with " + firstValue + " and " + + lastValue + " using strategy " + strategy, e); + } + } + return res; + } + + /** + * Merge the two given values. If only one of the values is defined, it + * always wins, otherwise the given strategy is applied. + * + * @hide + */ + @VisibleForTesting + public static @Nullable Object merge(@Strategy int strategy, + @Nullable Object first, @Nullable Object last) { + if (first == null) return last; + if (last == null) return first; + + if (first.getClass() != last.getClass()) { + throw new IllegalArgumentException("Merging requires consistent classes; first " + + first.getClass() + " last " + last.getClass()); + } + + switch (strategy) { + case STRATEGY_REJECT: + // Only actually reject when the values are different + if (Objects.deepEquals(first, last)) { + return first; + } else { + return null; + } + case STRATEGY_FIRST: + return first; + case STRATEGY_LAST: + return last; + case STRATEGY_COMPARABLE_MIN: + return comparableMin(first, last); + case STRATEGY_COMPARABLE_MAX: + return comparableMax(first, last); + case STRATEGY_NUMBER_ADD: + return numberAdd(first, last); + case STRATEGY_NUMBER_INCREMENT_FIRST: + return numberIncrementFirst(first, last); + case STRATEGY_BOOLEAN_AND: + return booleanAnd(first, last); + case STRATEGY_BOOLEAN_OR: + return booleanOr(first, last); + case STRATEGY_ARRAY_APPEND: + return arrayAppend(first, last); + case STRATEGY_ARRAY_LIST_APPEND: + return arrayListAppend(first, last); + default: + throw new UnsupportedOperationException(); + } + } + + @SuppressWarnings("unchecked") + private static @NonNull Object comparableMin(@NonNull Object first, @NonNull Object last) { + return ((Comparable) first).compareTo(last) < 0 ? first : last; + } + + @SuppressWarnings("unchecked") + private static @NonNull Object comparableMax(@NonNull Object first, @NonNull Object last) { + return ((Comparable) first).compareTo(last) >= 0 ? first : last; + } + + private static @NonNull Object numberAdd(@NonNull Object first, @NonNull Object last) { + if (first instanceof Integer) { + return ((Integer) first) + ((Integer) last); + } else if (first instanceof Long) { + return ((Long) first) + ((Long) last); + } else if (first instanceof Float) { + return ((Float) first) + ((Float) last); + } else if (first instanceof Double) { + return ((Double) first) + ((Double) last); + } else { + throw new IllegalArgumentException("Unable to add " + first.getClass()); + } + } + + private static @NonNull Number numberIncrementFirst(@NonNull Object first, + @NonNull Object last) { + if (first instanceof Integer) { + return ((Integer) first) + 1; + } else if (first instanceof Long) { + return ((Long) first) + 1L; + } else { + throw new IllegalArgumentException("Unable to add " + first.getClass()); + } + } + + private static @NonNull Object booleanAnd(@NonNull Object first, @NonNull Object last) { + return ((Boolean) first) && ((Boolean) last); + } + + private static @NonNull Object booleanOr(@NonNull Object first, @NonNull Object last) { + return ((Boolean) first) || ((Boolean) last); + } + + private static @NonNull Object arrayAppend(@NonNull Object first, @NonNull Object last) { + if (!first.getClass().isArray()) { + throw new IllegalArgumentException("Unable to append " + first.getClass()); + } + final Class clazz = first.getClass().getComponentType(); + final int firstLength = Array.getLength(first); + final int lastLength = Array.getLength(last); + final Object res = Array.newInstance(clazz, firstLength + lastLength); + System.arraycopy(first, 0, res, 0, firstLength); + System.arraycopy(last, 0, res, firstLength, lastLength); + return res; + } + + @SuppressWarnings("unchecked") + private static @NonNull Object arrayListAppend(@NonNull Object first, @NonNull Object last) { + if (!(first instanceof ArrayList)) { + throw new IllegalArgumentException("Unable to append " + first.getClass()); + } + final ArrayList firstList = (ArrayList) first; + final ArrayList lastList = (ArrayList) last; + final ArrayList res = new ArrayList<>(firstList.size() + lastList.size()); + res.addAll(firstList); + res.addAll(lastList); + return res; + } + + public static final @android.annotation.NonNull Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public BundleMerger createFromParcel(Parcel in) { + return new BundleMerger(in); + } + + @Override + public BundleMerger[] newArray(int size) { + return new BundleMerger[size]; + } + }; +} diff --git a/core/tests/coretests/src/android/os/BundleMergerTest.java b/core/tests/coretests/src/android/os/BundleMergerTest.java new file mode 100644 index 0000000000000..b7012ba66124c --- /dev/null +++ b/core/tests/coretests/src/android/os/BundleMergerTest.java @@ -0,0 +1,408 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file 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. + */ + +package android.os; + +import static android.os.BundleMerger.STRATEGY_ARRAY_APPEND; +import static android.os.BundleMerger.STRATEGY_ARRAY_LIST_APPEND; +import static android.os.BundleMerger.STRATEGY_BOOLEAN_AND; +import static android.os.BundleMerger.STRATEGY_BOOLEAN_OR; +import static android.os.BundleMerger.STRATEGY_COMPARABLE_MAX; +import static android.os.BundleMerger.STRATEGY_COMPARABLE_MIN; +import static android.os.BundleMerger.STRATEGY_FIRST; +import static android.os.BundleMerger.STRATEGY_LAST; +import static android.os.BundleMerger.STRATEGY_NUMBER_ADD; +import static android.os.BundleMerger.STRATEGY_NUMBER_INCREMENT_FIRST; +import static android.os.BundleMerger.STRATEGY_REJECT; +import static android.os.BundleMerger.merge; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import android.content.Intent; +import android.net.Uri; + +import androidx.test.filters.SmallTest; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@SmallTest +@RunWith(JUnit4.class) +public class BundleMergerTest { + /** + * Strategies are only applied when there is an actual conflict; in the + * absence of conflict we pick whichever value is defined. + */ + @Test + public void testNoConflict() throws Exception { + for (int strategy = Byte.MIN_VALUE; strategy < Byte.MAX_VALUE; strategy++) { + assertEquals(null, merge(strategy, null, null)); + assertEquals(10, merge(strategy, 10, null)); + assertEquals(20, merge(strategy, null, 20)); + } + } + + /** + * Strategies are only applied to identical data types; if there are mixed + * types we always reject the two conflicting values. + */ + @Test + public void testMixedTypes() throws Exception { + for (int strategy = Byte.MIN_VALUE; strategy < Byte.MAX_VALUE; strategy++) { + final int finalStrategy = strategy; + assertThrows(Exception.class, () -> { + merge(finalStrategy, 10, "foo"); + }); + assertThrows(Exception.class, () -> { + merge(finalStrategy, List.of("foo"), "bar"); + }); + assertThrows(Exception.class, () -> { + merge(finalStrategy, new String[] { "foo" }, "bar"); + }); + assertThrows(Exception.class, () -> { + merge(finalStrategy, Integer.valueOf(10), Long.valueOf(10)); + }); + } + } + + @Test + public void testStrategyReject() throws Exception { + assertEquals(null, merge(STRATEGY_REJECT, 10, 20)); + + // Identical values aren't technically a conflict, so they're passed + // through without being rejected + assertEquals(10, merge(STRATEGY_REJECT, 10, 10)); + assertArrayEquals(new int[] {10}, + (int[]) merge(STRATEGY_REJECT, new int[] {10}, new int[] {10})); + } + + @Test + public void testStrategyFirst() throws Exception { + assertEquals(10, merge(STRATEGY_FIRST, 10, 20)); + } + + @Test + public void testStrategyLast() throws Exception { + assertEquals(20, merge(STRATEGY_LAST, 10, 20)); + } + + @Test + public void testStrategyComparableMin() throws Exception { + assertEquals(10, merge(STRATEGY_COMPARABLE_MIN, 10, 20)); + assertEquals(10, merge(STRATEGY_COMPARABLE_MIN, 20, 10)); + assertEquals("a", merge(STRATEGY_COMPARABLE_MIN, "a", "z")); + assertEquals("a", merge(STRATEGY_COMPARABLE_MIN, "z", "a")); + + assertThrows(Exception.class, () -> { + merge(STRATEGY_COMPARABLE_MIN, new Binder(), new Binder()); + }); + } + + @Test + public void testStrategyComparableMax() throws Exception { + assertEquals(20, merge(STRATEGY_COMPARABLE_MAX, 10, 20)); + assertEquals(20, merge(STRATEGY_COMPARABLE_MAX, 20, 10)); + assertEquals("z", merge(STRATEGY_COMPARABLE_MAX, "a", "z")); + assertEquals("z", merge(STRATEGY_COMPARABLE_MAX, "z", "a")); + + assertThrows(Exception.class, () -> { + merge(STRATEGY_COMPARABLE_MAX, new Binder(), new Binder()); + }); + } + + @Test + public void testStrategyNumberAdd() throws Exception { + assertEquals(30, merge(STRATEGY_NUMBER_ADD, 10, 20)); + assertEquals(30, merge(STRATEGY_NUMBER_ADD, 20, 10)); + assertEquals(30L, merge(STRATEGY_NUMBER_ADD, 10L, 20L)); + assertEquals(30L, merge(STRATEGY_NUMBER_ADD, 20L, 10L)); + + assertThrows(Exception.class, () -> { + merge(STRATEGY_NUMBER_ADD, new Binder(), new Binder()); + }); + } + + @Test + public void testStrategyNumberIncrementFirst() throws Exception { + assertEquals(11, merge(STRATEGY_NUMBER_INCREMENT_FIRST, 10, 20)); + assertEquals(21, merge(STRATEGY_NUMBER_INCREMENT_FIRST, 20, 10)); + assertEquals(11L, merge(STRATEGY_NUMBER_INCREMENT_FIRST, 10L, 20L)); + assertEquals(21L, merge(STRATEGY_NUMBER_INCREMENT_FIRST, 20L, 10L)); + } + + @Test + public void testStrategyBooleanAnd() throws Exception { + assertEquals(false, merge(STRATEGY_BOOLEAN_AND, false, false)); + assertEquals(false, merge(STRATEGY_BOOLEAN_AND, true, false)); + assertEquals(false, merge(STRATEGY_BOOLEAN_AND, false, true)); + assertEquals(true, merge(STRATEGY_BOOLEAN_AND, true, true)); + + assertThrows(Exception.class, () -> { + merge(STRATEGY_BOOLEAN_AND, "True!", "False?"); + }); + } + + @Test + public void testStrategyBooleanOr() throws Exception { + assertEquals(false, merge(STRATEGY_BOOLEAN_OR, false, false)); + assertEquals(true, merge(STRATEGY_BOOLEAN_OR, true, false)); + assertEquals(true, merge(STRATEGY_BOOLEAN_OR, false, true)); + assertEquals(true, merge(STRATEGY_BOOLEAN_OR, true, true)); + + assertThrows(Exception.class, () -> { + merge(STRATEGY_BOOLEAN_OR, "True!", "False?"); + }); + } + + @Test + public void testStrategyArrayAppend() throws Exception { + assertArrayEquals(new int[] {}, + (int[]) merge(STRATEGY_ARRAY_APPEND, new int[] {}, new int[] {})); + assertArrayEquals(new int[] {10}, + (int[]) merge(STRATEGY_ARRAY_APPEND, new int[] {10}, new int[] {})); + assertArrayEquals(new int[] {20}, + (int[]) merge(STRATEGY_ARRAY_APPEND, new int[] {}, new int[] {20})); + assertArrayEquals(new int[] {10, 20}, + (int[]) merge(STRATEGY_ARRAY_APPEND, new int[] {10}, new int[] {20})); + assertArrayEquals(new int[] {10, 30, 20, 40}, + (int[]) merge(STRATEGY_ARRAY_APPEND, new int[] {10, 30}, new int[] {20, 40})); + assertArrayEquals(new String[] {"a", "b"}, + (String[]) merge(STRATEGY_ARRAY_APPEND, new String[] {"a"}, new String[] {"b"})); + + assertThrows(Exception.class, () -> { + merge(STRATEGY_ARRAY_APPEND, 10, 20); + }); + } + + @Test + public void testStrategyArrayListAppend() throws Exception { + assertEquals(arrayListOf(), + merge(STRATEGY_ARRAY_LIST_APPEND, arrayListOf(), arrayListOf())); + assertEquals(arrayListOf(10), + merge(STRATEGY_ARRAY_LIST_APPEND, arrayListOf(10), arrayListOf())); + assertEquals(arrayListOf(20), + merge(STRATEGY_ARRAY_LIST_APPEND, arrayListOf(), arrayListOf(20))); + assertEquals(arrayListOf(10, 20), + merge(STRATEGY_ARRAY_LIST_APPEND, arrayListOf(10), arrayListOf(20))); + assertEquals(arrayListOf(10, 30, 20, 40), + merge(STRATEGY_ARRAY_LIST_APPEND, arrayListOf(10, 30), arrayListOf(20, 40))); + assertEquals(arrayListOf("a", "b"), + merge(STRATEGY_ARRAY_LIST_APPEND, arrayListOf("a"), arrayListOf("b"))); + + assertThrows(Exception.class, () -> { + merge(STRATEGY_ARRAY_LIST_APPEND, 10, 20); + }); + } + + @Test + public void testMerge_Simple() throws Exception { + final BundleMerger merger = new BundleMerger(); + final Bundle probe = new Bundle(); + probe.putInt(Intent.EXTRA_INDEX, 42); + + assertEquals(null, merger.merge(null, null)); + assertEquals(probe.keySet(), merger.merge(probe, null).keySet()); + assertEquals(probe.keySet(), merger.merge(null, probe).keySet()); + assertEquals(probe.keySet(), merger.merge(probe, probe).keySet()); + } + + /** + * Verify that we can merge parcelables present in the base classpath, since + * everyone on the device will be able to unpack them. + */ + @Test + public void testMerge_Parcelable_BCP() throws Exception { + final BundleMerger merger = new BundleMerger(); + merger.setMergeStrategy(Intent.EXTRA_STREAM, STRATEGY_COMPARABLE_MIN); + + Bundle a = new Bundle(); + a.putParcelable(Intent.EXTRA_STREAM, Uri.parse("http://example.com")); + a = parcelAndUnparcel(a); + + Bundle b = new Bundle(); + b.putParcelable(Intent.EXTRA_STREAM, Uri.parse("http://example.net")); + b = parcelAndUnparcel(b); + + assertEquals(Uri.parse("http://example.com"), + merger.merge(a, b).getParcelable(Intent.EXTRA_STREAM, Uri.class)); + assertEquals(Uri.parse("http://example.com"), + merger.merge(b, a).getParcelable(Intent.EXTRA_STREAM, Uri.class)); + } + + /** + * Verify that we tiptoe around custom parcelables while still merging other + * known data types. Custom parcelables aren't in the base classpath, so not + * everyone on the device will be able to unpack them. + */ + @Test + public void testMerge_Parcelable_Custom() throws Exception { + final BundleMerger merger = new BundleMerger(); + merger.setMergeStrategy(Intent.EXTRA_INDEX, STRATEGY_NUMBER_ADD); + + Bundle a = new Bundle(); + a.putInt(Intent.EXTRA_INDEX, 10); + a.putString(Intent.EXTRA_CC, "foo@bar.com"); + a.putParcelable(Intent.EXTRA_SUBJECT, new ExplodingParcelable()); + a = parcelAndUnparcel(a); + + Bundle b = new Bundle(); + b.putInt(Intent.EXTRA_INDEX, 20); + a.putString(Intent.EXTRA_BCC, "foo@baz.com"); + b.putParcelable(Intent.EXTRA_STREAM, new ExplodingParcelable()); + b = parcelAndUnparcel(b); + + Bundle ab = merger.merge(a, b); + assertEquals(Set.of(Intent.EXTRA_INDEX, Intent.EXTRA_CC, Intent.EXTRA_BCC, + Intent.EXTRA_SUBJECT, Intent.EXTRA_STREAM), ab.keySet()); + assertEquals(30, ab.getInt(Intent.EXTRA_INDEX)); + assertEquals("foo@bar.com", ab.getString(Intent.EXTRA_CC)); + assertEquals("foo@baz.com", ab.getString(Intent.EXTRA_BCC)); + + // And finally, make sure that if we try unpacking one of our custom + // values that we actually explode + assertThrows(BadParcelableException.class, () -> { + ab.getParcelable(Intent.EXTRA_SUBJECT, ExplodingParcelable.class); + }); + assertThrows(BadParcelableException.class, () -> { + ab.getParcelable(Intent.EXTRA_STREAM, ExplodingParcelable.class); + }); + } + + @Test + public void testMerge_PackageChanged() throws Exception { + final BundleMerger merger = new BundleMerger(); + merger.setMergeStrategy(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST, STRATEGY_ARRAY_APPEND); + + final Bundle first = new Bundle(); + first.putInt(Intent.EXTRA_UID, 10001); + first.putStringArray(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST, new String[] { + "com.example.Foo", + }); + + final Bundle second = new Bundle(); + second.putInt(Intent.EXTRA_UID, 10001); + second.putStringArray(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST, new String[] { + "com.example.Bar", + "com.example.Baz", + }); + + final Bundle res = merger.merge(first, second); + assertEquals(10001, res.getInt(Intent.EXTRA_UID)); + assertArrayEquals(new String[] { + "com.example.Foo", "com.example.Bar", "com.example.Baz", + }, res.getStringArray(Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST)); + } + + /** + * Each event in isolation reports "zero events dropped", but if we need to + * merge them together, then we start incrementing. + */ + @Test + public void testMerge_DropBox() throws Exception { + final BundleMerger merger = new BundleMerger(); + merger.setMergeStrategy(DropBoxManager.EXTRA_TIME, + STRATEGY_COMPARABLE_MAX); + merger.setMergeStrategy(DropBoxManager.EXTRA_DROPPED_COUNT, + STRATEGY_NUMBER_INCREMENT_FIRST); + + final long now = System.currentTimeMillis(); + final Bundle a = new Bundle(); + a.putString(DropBoxManager.EXTRA_TAG, "system_server_strictmode"); + a.putLong(DropBoxManager.EXTRA_TIME, now); + a.putInt(DropBoxManager.EXTRA_DROPPED_COUNT, 0); + + final Bundle b = new Bundle(); + b.putString(DropBoxManager.EXTRA_TAG, "system_server_strictmode"); + b.putLong(DropBoxManager.EXTRA_TIME, now + 1000); + b.putInt(DropBoxManager.EXTRA_DROPPED_COUNT, 0); + + final Bundle c = new Bundle(); + c.putString(DropBoxManager.EXTRA_TAG, "system_server_strictmode"); + c.putLong(DropBoxManager.EXTRA_TIME, now + 2000); + c.putInt(DropBoxManager.EXTRA_DROPPED_COUNT, 0); + + final Bundle ab = merger.merge(a, b); + assertEquals("system_server_strictmode", ab.getString(DropBoxManager.EXTRA_TAG)); + assertEquals(now + 1000, ab.getLong(DropBoxManager.EXTRA_TIME)); + assertEquals(1, ab.getInt(DropBoxManager.EXTRA_DROPPED_COUNT)); + + final Bundle abc = merger.merge(ab, c); + assertEquals("system_server_strictmode", abc.getString(DropBoxManager.EXTRA_TAG)); + assertEquals(now + 2000, abc.getLong(DropBoxManager.EXTRA_TIME)); + assertEquals(2, abc.getInt(DropBoxManager.EXTRA_DROPPED_COUNT)); + } + + private static ArrayList arrayListOf(Object... values) { + final ArrayList res = new ArrayList<>(values.length); + for (Object value : values) { + res.add(value); + } + return res; + } + + private static Bundle parcelAndUnparcel(Bundle input) { + final Parcel parcel = Parcel.obtain(); + try { + input.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + return Bundle.CREATOR.createFromParcel(parcel); + } finally { + parcel.recycle(); + } + } + + /** + * Object that only offers to parcel itself; if something tries unparceling + * it, it will "explode" by throwing an exception. + *

+ * Useful for verifying interactions that must leave unknown data in a + * parceled state. + */ + public static class ExplodingParcelable implements Parcelable { + public ExplodingParcelable() { + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeInt(42); + } + + public static final Creator CREATOR = + new Creator() { + @Override + public ExplodingParcelable createFromParcel(Parcel in) { + throw new BadParcelableException("exploding!"); + } + + @Override + public ExplodingParcelable[] newArray(int size) { + throw new BadParcelableException("exploding!"); + } + }; + } +}