diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DefaultValueLiveData.java b/app/src/main/java/org/thoughtcrime/securesms/util/DefaultValueLiveData.java index ea57c5a45e..2fe907f0ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DefaultValueLiveData.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DefaultValueLiveData.java @@ -11,6 +11,7 @@ public class DefaultValueLiveData extends MutableLiveData { private final T defaultValue; public DefaultValueLiveData(@NonNull T defaultValue) { + super(defaultValue); this.defaultValue = defaultValue; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java new file mode 100644 index 0000000000..ba838bc6b2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; + +public final class LiveDataUtil { + + private LiveDataUtil() { + } + + /** + * Once there is non-null data on both input {@link LiveData}, the {@link Combine} function is run + * and produces a live data of the combined data. + *

+ * As each live data changes, the combine function is re-run, and a new value is emitted always + * with the latest, non-null values. + */ + public static LiveData combineLatest(@NonNull LiveData a, + @NonNull LiveData b, + @NonNull Combine combine) { + return new CombineLiveData<>(a, b, combine); + } + + public interface Combine { + @NonNull R apply(@NonNull A a, @NonNull B b); + } + + private static final class CombineLiveData extends MediatorLiveData { + private A a; + private B b; + + CombineLiveData(LiveData liveDataA, LiveData liveDataB, Combine combine) { + if (liveDataA == liveDataB) { + + addSource(liveDataA, (a) -> { + if (a != null) { + this.a = a; + //noinspection unchecked: A is B if live datas are same instance + this.b = (B) a; + setValue(combine.apply(a, b)); + } + }); + + } else { + + addSource(liveDataA, (a) -> { + if (a != null) { + this.a = a; + if (b != null) { + setValue(combine.apply(a, b)); + } + } + }); + + addSource(liveDataB, (b) -> { + if (b != null) { + this.b = b; + if (a != null) { + setValue(combine.apply(a, b)); + } + } + }); + } + } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataRule.java b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataRule.java new file mode 100644 index 0000000000..1a7c37a588 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataRule.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.annotation.NonNull; +import androidx.arch.core.executor.ArchTaskExecutor; +import androidx.arch.core.executor.TaskExecutor; + +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; + +/** + * Copy of androidx.arch.core.executor.testing.InstantTaskExecutorRule. + *

+ * I didn't want to bring in androidx.arch.core:core-testing at this time. + */ +public final class LiveDataRule extends TestWatcher { + @Override + protected void starting(Description description) { + super.starting(description); + + ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() { + @Override + public void executeOnDiskIO(@NonNull Runnable runnable) { + runnable.run(); + } + + @Override + public void postToMainThread(@NonNull Runnable runnable) { + runnable.run(); + } + + @Override + public boolean isMainThread() { + return true; + } + }); + } + + @Override + protected void finished(Description description) { + super.finished(description); + ArchTaskExecutor.getInstance().setDelegate(null); + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataTestUtil.java b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataTestUtil.java new file mode 100644 index 0000000000..f7515efc86 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataTestUtil.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertFalse; + +public final class LiveDataTestUtil { + + /** + * Observes and then instantly un-observes the supplied live data. + *

+ * This will therefore only work in conjunction with {@link LiveDataRule}. + */ + public static T getValue(final LiveData liveData) { + AtomicReference data = new AtomicReference<>(); + Observer observer = data::set; + + liveData.observeForever(observer); + liveData.removeObserver(observer); + + return data.get(); + } + + public static void assertNoValue(final LiveData liveData) { + AtomicReference data = new AtomicReference<>(false); + Observer observer = newValue -> data.set(true); + + liveData.observeForever(observer); + liveData.removeObserver(observer); + + assertFalse("Expected no value", data.get()); + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest.java new file mode 100644 index 0000000000..01c726eabc --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtilTest.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.util.livedata; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; + +import static org.junit.Assert.assertEquals; +import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.assertNoValue; +import static org.thoughtcrime.securesms.util.livedata.LiveDataTestUtil.getValue; + +public final class LiveDataUtilTest { + + @Rule + public TestRule rule = new LiveDataRule(); + + @Test + public void initially_no_value() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a + b); + + assertNoValue(combined); + } + + @Test + public void no_value_after_just_a() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a + b); + + liveDataA.setValue("Hello, "); + + assertNoValue(combined); + } + + @Test + public void no_value_after_just_b() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a + b); + + liveDataB.setValue("World!"); + + assertNoValue(combined); + } + + @Test + public void combined_value_after_a_and_b() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a + b); + + liveDataA.setValue("Hello, "); + liveDataB.setValue("World!"); + + assertEquals("Hello, World!", getValue(combined)); + } + + @Test + public void on_update_a() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a + b); + + liveDataA.setValue("Hello, "); + liveDataB.setValue("World!"); + + assertEquals("Hello, World!", getValue(combined)); + + liveDataA.setValue("Welcome, "); + assertEquals("Welcome, World!", getValue(combined)); + } + + @Test + public void on_update_b() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a + b); + + liveDataA.setValue("Hello, "); + liveDataB.setValue("World!"); + + assertEquals("Hello, World!", getValue(combined)); + + liveDataB.setValue("Joe!"); + assertEquals("Hello, Joe!", getValue(combined)); + } + + @Test + public void combined_same_instance() { + MutableLiveData liveDataA = new MutableLiveData<>(); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataA, (a, b) -> a + b); + + liveDataA.setValue("Echo! "); + + assertEquals("Echo! Echo! ", getValue(combined)); + } + + @Test + public void on_a_set_before_combine() { + MutableLiveData liveDataA = new MutableLiveData<>(); + MutableLiveData liveDataB = new MutableLiveData<>(); + + liveDataA.setValue("Hello, "); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a + b); + + liveDataB.setValue("World!"); + + assertEquals("Hello, World!", getValue(combined)); + } + + @Test + public void on_default_values() { + MutableLiveData liveDataA = new DefaultValueLiveData<>(10); + MutableLiveData liveDataB = new DefaultValueLiveData<>(30); + + LiveData combined = LiveDataUtil.combineLatest(liveDataA, liveDataB, (a, b) -> a * b); + + assertEquals(Integer.valueOf(300), getValue(combined)); + } +}