Merge branch 'main' into feature/share-images-via-small-talk

This commit is contained in:
Adam Brown 2022-06-08 19:25:59 +01:00
commit 92ad630e45
15 changed files with 166 additions and 27 deletions

View File

@ -1,6 +1,8 @@
package test package test
import io.mockk.* import io.mockk.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
inline fun <T : Any, reified R> T.expect(crossinline block: suspend MockKMatcherScope.(T) -> R) { inline fun <T : Any, reified R> T.expect(crossinline block: suspend MockKMatcherScope.(T) -> R) {
coEvery { block(this@expect) } returns mockk(relaxed = true) coEvery { block(this@expect) } returns mockk(relaxed = true)
@ -16,11 +18,22 @@ fun <T, B> MockKStubScope<T, B>.delegateReturn() = object : Returns<T> {
} }
} }
fun <T, B> MockKStubScope<Flow<T>, B>.delegateEmit() = object : Emits<T> {
override fun emits(vararg values: T) {
answers(ConstantAnswer(flowOf(*values)))
}
}
fun <T> returns(block: (T) -> Unit) = object : Returns<T> { fun <T> returns(block: (T) -> Unit) = object : Returns<T> {
override fun returns(value: T) = block(value) override fun returns(value: T) = block(value)
override fun throws(value: Throwable) = throw value override fun throws(value: Throwable) = throw value
} }
interface Emits<T> {
fun emits(vararg values: T)
}
interface Returns<T> { interface Returns<T> {
fun returns(value: T) fun returns(value: T)
fun throws(value: Throwable) fun throws(value: Throwable)

View File

@ -20,7 +20,7 @@ internal class MergeWithLocalEchosUseCaseImpl(
val existingWithEcho = updateExistingEventsWithLocalEchoMeta(roomState, echosByEventId) val existingWithEcho = updateExistingEventsWithLocalEchoMeta(roomState, echosByEventId)
val sortedEvents = (existingWithEcho + uniqueEchos) val sortedEvents = (existingWithEcho + uniqueEchos)
.sortedByDescending { if (it is RoomEvent.Message) it.utcTimestamp else null } .sortedByDescending { it.utcTimestamp }
.distinctBy { it.eventId } .distinctBy { it.eventId }
return roomState.copy(events = sortedEvents) return roomState.copy(events = sortedEvents)
} }

View File

@ -218,6 +218,9 @@ private fun <T : RoomEvent> LazyItemScope.AlignedBubble(
@Composable @Composable
private fun MessageImage(content: BubbleContent<RoomEvent.Image>) { private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
val context = LocalContext.current
val fetcherFactory = remember { DecryptingFetcherFactory(context) }
Box(modifier = Modifier.padding(start = 6.dp)) { Box(modifier = Modifier.padding(start = 6.dp)) {
Box( Box(
Modifier Modifier
@ -245,8 +248,8 @@ private fun MessageImage(content: BubbleContent<RoomEvent.Image>) {
Image( Image(
modifier = Modifier.size(content.message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), modifier = Modifier.size(content.message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter( painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(context)
.fetcherFactory(DecryptingFetcherFactory(LocalContext.current)) .fetcherFactory(fetcherFactory)
.data(content.message) .data(content.message)
.build() .build()
), ),
@ -387,6 +390,8 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
.width(IntrinsicSize.Max) .width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 50.dp) .defaultMinSize(minWidth = 50.dp)
) { ) {
val context = LocalContext.current
val fetcherFactory = remember { DecryptingFetcherFactory(context) }
Column( Column(
Modifier Modifier
.background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground) .background(if (content.isNotSelf) SmallTalkTheme.extendedColors.otherBubbleReplyBackground else SmallTalkTheme.extendedColors.selfBubbleReplyBackground)
@ -415,8 +420,8 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
Image( Image(
modifier = Modifier.size(replyingTo.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), modifier = Modifier.size(replyingTo.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter( painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(context)
.fetcherFactory(DecryptingFetcherFactory(LocalContext.current)) .fetcherFactory(fetcherFactory)
.data(replyingTo) .data(replyingTo)
.build() .build()
), ),
@ -452,9 +457,9 @@ private fun ReplyBubbleContent(content: BubbleContent<RoomEvent.Reply>) {
Image( Image(
modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)), modifier = Modifier.size(message.imageMeta.scale(LocalDensity.current, LocalConfiguration.current)),
painter = rememberAsyncImagePainter( painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(context)
.data(content.message) .data(content.message)
.fetcherFactory(DecryptingFetcherFactory(LocalContext.current)) .fetcherFactory(fetcherFactory)
.build() .build()
), ),
contentDescription = null, contentDescription = null,

View File

@ -10,6 +10,7 @@ import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
private val A_ROOM_MESSAGE_EVENT = aRoomMessageEvent(eventId = anEventId("1")) private val A_ROOM_MESSAGE_EVENT = aRoomMessageEvent(eventId = anEventId("1"))
private val A_ROOM_IMAGE_MESSAGE_EVENT = aRoomMessageEvent(eventId = anEventId("2"))
private val A_LOCAL_ECHO_EVENT_ID = anEventId("2") private val A_LOCAL_ECHO_EVENT_ID = anEventId("2")
private const val A_LOCAL_ECHO_BODY = "body" private const val A_LOCAL_ECHO_BODY = "body"
private val A_ROOM_MEMBER = aRoomMember() private val A_ROOM_MEMBER = aRoomMember()
@ -21,7 +22,7 @@ class MergeWithLocalEchosUseCaseTest {
private val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(fakeLocalEchoMapper.instance) private val mergeWithLocalEchosUseCase = MergeWithLocalEchosUseCaseImpl(fakeLocalEchoMapper.instance)
@Test @Test
fun `given no local echos, when merging, then returns original state`() { fun `given no local echos, when merging text message, then returns original state`() {
val roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_EVENT)) val roomState = aRoomState(events = listOf(A_ROOM_MESSAGE_EVENT))
val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, emptyList()) val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, emptyList())
@ -29,6 +30,15 @@ class MergeWithLocalEchosUseCaseTest {
result shouldBeEqualTo roomState result shouldBeEqualTo roomState
} }
@Test
fun `given no local echos, when merging events, then returns original ordered by timestamp descending`() {
val roomState = aRoomState(events = listOf(A_ROOM_IMAGE_MESSAGE_EVENT.copy(utcTimestamp = 1500), A_ROOM_MESSAGE_EVENT.copy(utcTimestamp = 1000)))
val result = mergeWithLocalEchosUseCase.invoke(roomState, A_ROOM_MEMBER, emptyList())
result shouldBeEqualTo roomState.copy(events = roomState.events.sortedByDescending { it.utcTimestamp })
}
@Test @Test
fun `given local echo with sending state, when merging then maps to room event with local echo state`() { fun `given local echo with sending state, when merging then maps to room event with local echo state`() {
val second = createLocalEcho(A_LOCAL_ECHO_EVENT_ID, A_LOCAL_ECHO_BODY, state = MessageService.LocalEcho.State.Sending) val second = createLocalEcho(A_LOCAL_ECHO_EVENT_ID, A_LOCAL_ECHO_BODY, state = MessageService.LocalEcho.State.Sending)

View File

@ -31,7 +31,7 @@ class NotificationsModule(
fun credentialProvider() = credentialsStore fun credentialProvider() = credentialsStore
fun firebasePushTokenUseCase() = firebasePushTokenUseCase fun firebasePushTokenUseCase() = firebasePushTokenUseCase
fun roomStore() = roomStore fun roomStore() = roomStore
fun notificationsUseCase() = NotificationsUseCase( fun notificationsUseCase() = RenderNotificationsUseCase(
NotificationRenderer(notificationManager(), NotificationFactory(iconLoader, context, intentFactory), dispatchers), NotificationRenderer(notificationManager(), NotificationFactory(iconLoader, context, intentFactory), dispatchers),
ObserveUnreadNotificationsUseCaseImpl(roomStore), ObserveUnreadNotificationsUseCaseImpl(roomStore),
NotificationChannels(notificationManager()), NotificationChannels(notificationManager()),

View File

@ -7,7 +7,7 @@ import app.dapk.st.matrix.sync.RoomOverview
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
class NotificationsUseCase( class RenderNotificationsUseCase(
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase, private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase,
notificationChannels: NotificationChannels, notificationChannels: NotificationChannels,

View File

@ -1,6 +1,6 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import app.dapk.st.notifications.NotificationFixtures.aNotifications import fixture.NotificationFixtures.aNotifications
import fake.FakeNotificationFactory import fake.FakeNotificationFactory
import fake.FakeNotificationManager import fake.FakeNotificationManager
import fake.aFakeNotification import fake.aFakeNotification

View File

@ -1,14 +1,10 @@
package app.dapk.st.notifications package app.dapk.st.notifications
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomEvent import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomOverview import app.dapk.st.matrix.sync.RoomOverview
import fake.FakeRoomStore import fake.FakeRoomStore
import fixture.aRoomId import fixture.*
import fixture.aRoomMessageEvent import fixture.NotificationDiffFixtures.aNotificationDiff
import fixture.aRoomOverview
import fixture.anEventId
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -21,7 +17,7 @@ val A_MESSAGE_2 = aRoomMessageEvent(eventId = anEventId("2"), content = "world")
val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1")) val A_ROOM_OVERVIEW = aRoomOverview(roomId = aRoomId("1"))
val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2")) val A_ROOM_OVERVIEW_2 = aRoomOverview(roomId = aRoomId("2"))
class ObserveUnreadNotificationsUseCaseTest { class ObserveUnreadRenderNotificationsUseCaseTest {
private val fakeRoomStore = FakeRoomStore() private val fakeRoomStore = FakeRoomStore()
@ -94,12 +90,5 @@ class ObserveUnreadNotificationsUseCaseTest {
private fun givenNoInitialUnreads(vararg unreads: Map<RoomOverview, List<RoomEvent>>) = fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads)) private fun givenNoInitialUnreads(vararg unreads: Map<RoomOverview, List<RoomEvent>>) = fakeRoomStore.givenUnreadEvents(flowOf(NO_UNREADS, *unreads))
} }
private fun aNotificationDiff(
unchanged: Map<RoomId, List<EventId>> = emptyMap(),
changedOrNew: Map<RoomId, List<EventId>> = emptyMap(),
removed: Map<RoomId, List<EventId>> = emptyMap(),
newRooms: Set<RoomId> = emptySet(),
) = NotificationDiff(unchanged, changedOrNew, removed, newRooms)
private fun RoomOverview.withUnreads(vararg events: RoomEvent) = mapOf(this to events.toList()) private fun RoomOverview.withUnreads(vararg events: RoomEvent) = mapOf(this to events.toList())
private fun RoomOverview.toDiff(vararg events: RoomEvent) = mapOf(this.roomId to events.map { it.eventId }) private fun RoomOverview.toDiff(vararg events: RoomEvent) = mapOf(this.roomId to events.map { it.eventId })

View File

@ -0,0 +1,41 @@
package app.dapk.st.notifications
import fake.FakeNotificationChannels
import fake.FakeNotificationRenderer
import fake.FakeObserveUnreadNotificationsUseCase
import fixture.NotificationDiffFixtures.aNotificationDiff
import kotlinx.coroutines.test.runTest
import org.junit.Test
import test.expect
private val AN_UNREAD_NOTIFICATIONS = UnreadNotifications(emptyMap(), aNotificationDiff())
class RenderNotificationsUseCaseTest {
private val fakeNotificationRenderer = FakeNotificationRenderer()
private val fakeObserveUnreadNotificationsUseCase = FakeObserveUnreadNotificationsUseCase()
private val fakeNotificationChannels = FakeNotificationChannels().also {
it.instance.expect { it.initChannels() }
}
private val renderNotificationsUseCase = RenderNotificationsUseCase(
fakeNotificationRenderer.instance,
fakeObserveUnreadNotificationsUseCase,
fakeNotificationChannels.instance,
)
@Test
fun `when creating use case instance, then initiates channels`() {
fakeNotificationChannels.verifyInitiated()
}
@Test
fun `given renderable unread events, when listening for changes, then renders change`() = runTest {
fakeNotificationRenderer.instance.expect { it.render(any(), any(), any(), any()) }
fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS)
renderNotificationsUseCase.listenForNotificationChanges()
fakeNotificationRenderer.verifyRenders(AN_UNREAD_NOTIFICATIONS)
}
}

View File

@ -0,0 +1,13 @@
package fake
import app.dapk.st.notifications.NotificationChannels
import io.mockk.mockk
import io.mockk.verify
class FakeNotificationChannels {
val instance = mockk<NotificationChannels>()
fun verifyInitiated() {
verify { instance.initChannels() }
}
}

View File

@ -0,0 +1,23 @@
package fake
import app.dapk.st.notifications.NotificationRenderer
import app.dapk.st.notifications.UnreadNotifications
import io.mockk.coVerify
import io.mockk.mockk
class FakeNotificationRenderer {
val instance = mockk<NotificationRenderer>()
fun verifyRenders(vararg unreadNotifications: UnreadNotifications) {
unreadNotifications.forEach { unread ->
coVerify {
instance.render(
allUnread = unread.first,
removedRooms = unread.second.removed.keys,
roomsWithNewEvents = unread.second.changedOrNew.keys,
newRooms = unread.second.newRooms,
)
}
}
}
}

View File

@ -0,0 +1,10 @@
package fake
import app.dapk.st.notifications.ObserveUnreadNotificationsUseCase
import io.mockk.coEvery
import io.mockk.mockk
import test.delegateEmit
class FakeObserveUnreadNotificationsUseCase : ObserveUnreadNotificationsUseCase by mockk() {
fun given() = coEvery { this@FakeObserveUnreadNotificationsUseCase.invoke() }.delegateEmit()
}

View File

@ -0,0 +1,16 @@
package fixture
import app.dapk.st.matrix.common.EventId
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.notifications.NotificationDiff
object NotificationDiffFixtures {
fun aNotificationDiff(
unchanged: Map<RoomId, List<EventId>> = emptyMap(),
changedOrNew: Map<RoomId, List<EventId>> = emptyMap(),
removed: Map<RoomId, List<EventId>> = emptyMap(),
newRooms: Set<RoomId> = emptySet(),
) = NotificationDiff(unchanged, changedOrNew, removed, newRooms)
}

View File

@ -1,6 +1,8 @@
package app.dapk.st.notifications package fixture
import android.app.Notification import android.app.Notification
import app.dapk.st.notifications.NotificationDelegate
import app.dapk.st.notifications.Notifications
object NotificationFixtures { object NotificationFixtures {

View File

@ -14,6 +14,16 @@ fun aRoomMessageEvent(
edited: Boolean = false, edited: Boolean = false,
) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited) ) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, encryptedContent, edited)
fun aRoomImageMessageEvent(
eventId: EventId = anEventId(),
utcTimestamp: Long = 0L,
content: RoomEvent.Image.ImageMeta = anImageMeta(),
author: RoomMember = aRoomMember(),
meta: MessageMeta = MessageMeta.FromServer,
encryptedContent: RoomEvent.Message.MegOlmV1? = null,
edited: Boolean = false,
) = RoomEvent.Image(eventId, utcTimestamp, content, author, meta, encryptedContent, edited)
fun aRoomReplyMessageEvent( fun aRoomReplyMessageEvent(
message: RoomEvent = aRoomMessageEvent(), message: RoomEvent = aRoomMessageEvent(),
replyingTo: RoomEvent = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")), replyingTo: RoomEvent = aRoomMessageEvent(eventId = anEventId("in-reply-to-id")),
@ -34,4 +44,11 @@ fun aMegolmV1(
deviceId: DeviceId = aDeviceId(), deviceId: DeviceId = aDeviceId(),
senderKey: String = "a-sender-key", senderKey: String = "a-sender-key",
sessionId: SessionId = aSessionId(), sessionId: SessionId = aSessionId(),
) = RoomEvent.Message.MegOlmV1(cipherText, deviceId, senderKey, sessionId) ) = RoomEvent.Message.MegOlmV1(cipherText, deviceId, senderKey, sessionId)
fun anImageMeta(
width: Int? = 100,
height: Int? = 100,
url: String = "https://a-url.com",
keys: RoomEvent.Image.ImageMeta.Keys? = null
) = RoomEvent.Image.ImageMeta(width, height, url, keys)