mirror of
https://github.com/ouchadam/small-talk.git
synced 2025-03-24 07:50:11 +01:00
Merge pull request #251 from ouchadam/release-candidate
[Auto] Release Candidate
This commit is contained in:
commit
a242f8238d
32
README.md
32
README.md
@ -86,4 +86,36 @@
|
||||
|
||||
---
|
||||
|
||||
### Data and Privacy
|
||||
|
||||
- Messages once decrypted are stored as plain text within `SmallTalk's` database. _Always encrypted messages_ comes at the cost of performance and limits features like local search. If maximum security is your number priority, `SmallTalk` is not the best option. This database is not easily accessed without rooting or external hardware.
|
||||
|
||||
- (Not yet implemented and may be configurable) Images once decrypted are stored in their plain form within the devices media directories, organised by room metadata.
|
||||
|
||||
- Push notifications contain no sensitive data by using the [event_id_only](https://github.com/ouchadam/small-talk/blob/main/matrix/services/push/src/main/kotlin/app/dapk/st/matrix/push/internal/RegisterPushUseCase.kt#L31) configuration. Push notifications are used as a _push to sync_ mechanism, where the on device sync fetches the actual contents.
|
||||
|
||||
- Passwords are **NEVER** stored within `SmallTalk`.
|
||||
|
||||
- `SmallTalk` does not explicitly talk to servers other than your home-server or track what you do. __*__
|
||||
- __*__ There is no `SmallTalk` server capturing data from the application however the Google variant likely includes transitive telemetrics through the use of `Firebase` and `Google Play Services` integrations.
|
||||
|
||||
- `SmallTalk` is completely free and will never feature adverts or paid app features.
|
||||
|
||||
---
|
||||
|
||||
`SmallTalk` comes in two flavours, `Google` and `FOSS`
|
||||
|
||||
##### Google
|
||||
- Available through the [Google Play Store](https://play.google.com/store/apps/details?id=app.dapk.st) and [Github Releases](https://github.com/ouchadam/small-talk/releases).
|
||||
- Automatic crash and non fatal error tracking via [Firebase Crashlytics](https://firebase.google.com/products/crashlytics).
|
||||
- Push notifications provided through [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging).
|
||||
|
||||
##### FOSS
|
||||
- Available through the [IzzySoft's F-Droid Repository](https://android.izzysoft.de/repo) and [Github Releases](https://github.com/ouchadam/small-talk/releases).
|
||||
- No Google or Firebase services (and their transitive telemetrics).
|
||||
- No crash tracking.
|
||||
- No push notifications by default, a separate [UnifiedPush](https://unifiedpush.org/) [distributor](https://unifiedpush.org/users/distributors/) is required.
|
||||
|
||||
---
|
||||
|
||||
#### Join the conversation @ https://matrix.to/#/#small-talk:iswell.cool
|
||||
|
@ -11,7 +11,7 @@ interface ChatEngine : TaskRunner {
|
||||
|
||||
fun directory(): Flow<DirectoryState>
|
||||
fun invites(): Flow<InviteState>
|
||||
fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow<MessengerState>
|
||||
fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow<MessengerPageState>
|
||||
|
||||
fun notificationsMessages(): Flow<UnreadNotifications>
|
||||
fun notificationsInvites(): Flow<InviteNotification>
|
||||
@ -36,6 +36,8 @@ interface ChatEngine : TaskRunner {
|
||||
|
||||
fun pushHandler(): PushHandler
|
||||
|
||||
suspend fun muteRoom(roomId: RoomId)
|
||||
suspend fun unmuteRoom(roomId: RoomId)
|
||||
}
|
||||
|
||||
interface TaskRunner {
|
||||
|
@ -13,7 +13,8 @@ typealias InviteState = List<RoomInvite>
|
||||
data class DirectoryItem(
|
||||
val overview: RoomOverview,
|
||||
val unreadCount: UnreadCount,
|
||||
val typing: Typing?
|
||||
val typing: Typing?,
|
||||
val isMuted: Boolean,
|
||||
)
|
||||
|
||||
data class RoomOverview(
|
||||
@ -84,10 +85,11 @@ sealed interface ImportResult {
|
||||
data class Update(val importedKeysCount: Long) : ImportResult
|
||||
}
|
||||
|
||||
data class MessengerState(
|
||||
data class MessengerPageState(
|
||||
val self: UserId,
|
||||
val roomState: RoomState,
|
||||
val typing: Typing?
|
||||
val typing: Typing?,
|
||||
val isMuted: Boolean,
|
||||
)
|
||||
|
||||
data class RoomState(
|
||||
@ -122,6 +124,15 @@ sealed class RoomEvent {
|
||||
|
||||
}
|
||||
|
||||
data class Redacted(
|
||||
override val eventId: EventId,
|
||||
override val utcTimestamp: Long,
|
||||
override val author: RoomMember,
|
||||
) : RoomEvent() {
|
||||
override val edited: Boolean = false
|
||||
override val meta: MessageMeta = MessageMeta.FromServer
|
||||
}
|
||||
|
||||
data class Message(
|
||||
override val eventId: EventId,
|
||||
override val utcTimestamp: Long,
|
||||
@ -129,7 +140,6 @@ sealed class RoomEvent {
|
||||
override val author: RoomMember,
|
||||
override val meta: MessageMeta,
|
||||
override val edited: Boolean = false,
|
||||
val redacted: Boolean = false,
|
||||
) : RoomEvent()
|
||||
|
||||
data class Reply(
|
||||
|
@ -6,8 +6,9 @@ import app.dapk.st.matrix.common.*
|
||||
fun aMessengerState(
|
||||
self: UserId = aUserId(),
|
||||
roomState: RoomState,
|
||||
typing: Typing? = null
|
||||
) = MessengerState(self, roomState, typing)
|
||||
typing: Typing? = null,
|
||||
isMuted: Boolean = false,
|
||||
) = MessengerPageState(self, roomState, typing, isMuted)
|
||||
|
||||
fun aRoomOverview(
|
||||
roomId: RoomId = aRoomId(),
|
||||
@ -27,8 +28,7 @@ fun anEncryptedRoomMessageEvent(
|
||||
author: RoomMember = aRoomMember(),
|
||||
meta: MessageMeta = MessageMeta.FromServer,
|
||||
edited: Boolean = false,
|
||||
redacted: Boolean = false,
|
||||
) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, edited, redacted)
|
||||
) = RoomEvent.Message(eventId, utcTimestamp, content, author, meta, edited)
|
||||
|
||||
fun aRoomImageMessageEvent(
|
||||
eventId: EventId = anEventId(),
|
||||
@ -63,4 +63,15 @@ fun anImageMeta(
|
||||
fun aRoomState(
|
||||
roomOverview: RoomOverview = aRoomOverview(),
|
||||
events: List<RoomEvent> = listOf(aRoomMessageEvent()),
|
||||
) = RoomState(roomOverview, events)
|
||||
) = RoomState(roomOverview, events)
|
||||
|
||||
fun aRoomInvite(
|
||||
from: RoomMember = aRoomMember(),
|
||||
roomId: RoomId = aRoomId(),
|
||||
inviteMeta: RoomInvite.InviteMeta = RoomInvite.InviteMeta.DirectMessage,
|
||||
) = RoomInvite(from, roomId, inviteMeta)
|
||||
|
||||
fun aTypingEvent(
|
||||
roomId: RoomId = aRoomId(),
|
||||
members: List<RoomMember> = listOf(aRoomMember())
|
||||
) = Typing(roomId, members)
|
28
core/src/main/kotlin/app/dapk/st/core/JobBag.kt
Normal file
28
core/src/main/kotlin/app/dapk/st/core/JobBag.kt
Normal file
@ -0,0 +1,28 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class JobBag {
|
||||
|
||||
private val jobs = mutableMapOf<String, Job>()
|
||||
|
||||
fun replace(key: String, job: Job) {
|
||||
jobs[key]?.cancel()
|
||||
jobs[key] = job
|
||||
}
|
||||
|
||||
fun replace(key: KClass<*>, job: Job) {
|
||||
jobs[key.java.canonicalName]?.cancel()
|
||||
jobs[key.java.canonicalName] = job
|
||||
}
|
||||
|
||||
fun cancel(key: String) {
|
||||
jobs.remove(key)?.cancel()
|
||||
}
|
||||
|
||||
fun cancel(key: KClass<*>) {
|
||||
jobs.remove(key.java.canonicalName)?.cancel()
|
||||
}
|
||||
|
||||
}
|
@ -17,4 +17,5 @@ suspend fun CachedPreferences.readBoolean(key: String, defaultValue: Boolean) =
|
||||
.toBooleanStrict()
|
||||
|
||||
suspend fun Preferences.readBoolean(key: String) = this.readString(key)?.toBooleanStrict()
|
||||
suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString())
|
||||
suspend fun Preferences.store(key: String, value: Boolean) = this.store(key, value.toString())
|
||||
|
||||
|
@ -20,3 +20,26 @@ suspend fun <T> Flow<T>.firstOrNull(count: Int, predicate: suspend (T) -> Boolea
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
inline fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
flow: Flow<T1>,
|
||||
flow2: Flow<T2>,
|
||||
flow3: Flow<T3>,
|
||||
flow4: Flow<T4>,
|
||||
flow5: Flow<T5>,
|
||||
flow6: Flow<T6>,
|
||||
crossinline transform: suspend (T1, T2, T3, T4, T5, T6) -> R
|
||||
): Flow<R> {
|
||||
return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
transform(
|
||||
args[0] as T1,
|
||||
args[1] as T2,
|
||||
args[2] as T3,
|
||||
args[3] as T4,
|
||||
args[4] as T5,
|
||||
args[5] as T6,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
9
core/src/testFixtures/kotlin/fake/FakeJobBag.kt
Normal file
9
core/src/testFixtures/kotlin/fake/FakeJobBag.kt
Normal file
@ -0,0 +1,9 @@
|
||||
package fake
|
||||
|
||||
import app.dapk.st.core.JobBag
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeJobBag {
|
||||
val instance = mockk<JobBag>()
|
||||
}
|
||||
|
@ -6,7 +6,11 @@ import kotlinx.coroutines.test.runTest
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) {
|
||||
runTest { testBody(ExpectTest(coroutineContext)) }
|
||||
runTest {
|
||||
val expectTest = ExpectTest(coroutineContext)
|
||||
testBody(expectTest)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope {
|
||||
@ -24,6 +28,11 @@ class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestSc
|
||||
expects.add(times to { block(this@expectUnit) })
|
||||
}
|
||||
|
||||
override fun <T> T.expect(times: Int, block: suspend MockKMatcherScope.(T) -> Unit) {
|
||||
coJustRun { block(this@expect) }
|
||||
expects.add(times to { block(this@expect) })
|
||||
}
|
||||
|
||||
override fun <T> T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit) {
|
||||
groups.add { block(this@captureExpects) }
|
||||
}
|
||||
@ -34,5 +43,6 @@ private fun Any.ignore() = Unit
|
||||
interface ExpectTestScope : CoroutineScope {
|
||||
fun verifyExpects()
|
||||
fun <T> T.expectUnit(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit)
|
||||
fun <T> T.expect(times: Int = 1, block: suspend MockKMatcherScope.(T) -> Unit)
|
||||
fun <T> T.captureExpects(block: suspend MockKMatcherScope.(T) -> Unit)
|
||||
}
|
@ -8,6 +8,9 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -31,12 +34,14 @@ import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
|
||||
private val ENCRYPTED_MESSAGE = RichText(listOf(RichText.Part.Normal("Encrypted message")))
|
||||
private val DELETED_MESSAGE = RichText(listOf(RichText.Part.Italic("Message deleted")))
|
||||
|
||||
sealed interface BubbleModel {
|
||||
val event: Event
|
||||
|
||||
data class Text(val content: RichText, override val event: Event) : BubbleModel
|
||||
data class Encrypted(override val event: Event) : BubbleModel
|
||||
data class Redacted(override val event: Event) : BubbleModel
|
||||
data class Image(val imageContent: ImageContent, val imageRequest: ImageRequest, override val event: Event) : BubbleModel {
|
||||
data class ImageContent(val width: Int?, val height: Int?, val url: String)
|
||||
}
|
||||
@ -64,6 +69,7 @@ fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable ()
|
||||
is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick)
|
||||
is BubbleModel.Image -> ImageBubble(bubble, model, status, onItemClick = { actions.onImageClick(model) }, itemisedLongClick)
|
||||
is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
|
||||
is BubbleModel.Redacted -> RedactedBubble(bubble, model, status)
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +131,7 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
|
||||
when (val replyingTo = model.replyingTo) {
|
||||
is BubbleModel.Text -> {
|
||||
Text(
|
||||
text = replyingTo.content.toAnnotatedText(),
|
||||
text = replyingTo.content.toAnnotatedText(bubble.isSelf),
|
||||
color = bubble.textColor().copy(alpha = 0.8f),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
@ -156,6 +162,8 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
|
||||
is BubbleModel.Reply -> {
|
||||
// TODO - a reply to a reply
|
||||
}
|
||||
|
||||
is BubbleModel.Redacted -> RedactedContent(bubble)
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,6 +188,8 @@ private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @C
|
||||
is BubbleModel.Reply -> {
|
||||
// TODO - a reply to a reply
|
||||
}
|
||||
|
||||
is BubbleModel.Redacted -> RedactedContent(bubble)
|
||||
}
|
||||
|
||||
Footer(model.event, bubble, status)
|
||||
@ -206,10 +216,29 @@ private fun Int.scalerFor(max: Float): Float {
|
||||
return max / this
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RedactedBubble(bubble: BubbleMeta, model: BubbleModel.Redacted, status: @Composable () -> Unit) {
|
||||
Bubble(bubble) {
|
||||
if (bubble.isNotSelf()) {
|
||||
AuthorName(model.event, bubble)
|
||||
}
|
||||
RedactedContent(bubble)
|
||||
Footer(model.event, bubble, status)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RedactedContent(bubble: BubbleMeta) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp, end = 4.dp)) {
|
||||
Icon(modifier = Modifier.height(20.dp), imageVector = Icons.Outlined.DeleteOutline, contentDescription = null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
TextContent(bubble, text = DELETED_MESSAGE, fontSize = 13)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
private fun Bubble(bubble: BubbleMeta, onItemClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, content: @Composable () -> Unit) {
|
||||
Box(modifier = Modifier.padding(start = 6.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
@ -217,7 +246,7 @@ private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: ()
|
||||
.clip(bubble.shape)
|
||||
.background(bubble.background)
|
||||
.height(IntrinsicSize.Max)
|
||||
.combinedClickable(onLongClick = onLongClick, onClick = onItemClick),
|
||||
.combinedClickable(onLongClick = onLongClick, onClick = onItemClick ?: {}),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
@ -247,12 +276,16 @@ private fun Footer(event: BubbleModel.Event, bubble: BubbleMeta, status: @Compos
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextContent(bubble: BubbleMeta, text: RichText) {
|
||||
val annotatedText = text.toAnnotatedText()
|
||||
private fun TextContent(bubble: BubbleMeta, text: RichText, isAlternative: Boolean = false, fontSize: Int = 15) {
|
||||
val annotatedText = text.toAnnotatedText(bubble.isSelf)
|
||||
val uriHandler = LocalUriHandler.current
|
||||
ClickableText(
|
||||
text = annotatedText,
|
||||
style = TextStyle(color = bubble.textColor(), fontSize = 15.sp, textAlign = TextAlign.Start),
|
||||
style = TextStyle(
|
||||
color = if (isAlternative) bubble.textColor().copy(alpha = 0.8f) else bubble.textColor(),
|
||||
fontSize = fontSize.sp,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
onClick = {
|
||||
annotatedText.getStringAnnotations("url", it, it).firstOrNull()?.let {
|
||||
@ -262,16 +295,19 @@ private fun TextContent(bubble: BubbleMeta, text: RichText) {
|
||||
)
|
||||
}
|
||||
|
||||
val hyperLinkStyle = SpanStyle(
|
||||
color = Color(0xff64B5F6),
|
||||
@Composable
|
||||
private fun nameStyle(isSelf: Boolean) = SpanStyle(
|
||||
color = if (isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun hyperlinkStyle(isSelf: Boolean) = SpanStyle(
|
||||
color = if (isSelf) SmallTalkTheme.extendedColors.onSelfBubble else SmallTalkTheme.extendedColors.onOthersBubble,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
val nameStyle = SpanStyle(
|
||||
color = Color(0xff64B5F6),
|
||||
)
|
||||
|
||||
fun RichText.toAnnotatedText() = buildAnnotatedString {
|
||||
@Composable
|
||||
fun RichText.toAnnotatedText(isSelf: Boolean) = buildAnnotatedString {
|
||||
parts.forEach {
|
||||
when (it) {
|
||||
is RichText.Part.Bold -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(it.content) }
|
||||
@ -279,12 +315,12 @@ fun RichText.toAnnotatedText() = buildAnnotatedString {
|
||||
is RichText.Part.Italic -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(it.content) }
|
||||
is RichText.Part.Link -> {
|
||||
pushStringAnnotation("url", annotation = it.url)
|
||||
withStyle(hyperLinkStyle) { append(it.label) }
|
||||
withStyle(hyperlinkStyle(isSelf)) { append(it.label) }
|
||||
pop()
|
||||
}
|
||||
|
||||
is RichText.Part.Normal -> append(it.content)
|
||||
is RichText.Part.Person -> withStyle(nameStyle) {
|
||||
is RichText.Part.Person -> withStyle(nameStyle(isSelf)) {
|
||||
append("@${it.displayName.substringBefore(':').removePrefix("@")}")
|
||||
}
|
||||
}
|
||||
|
@ -61,4 +61,4 @@ data class SpiderPage<T>(
|
||||
)
|
||||
|
||||
@JvmInline
|
||||
value class Route<S>(val value: String)
|
||||
value class Route<out S>(val value: String)
|
@ -5,4 +5,5 @@ dependencies {
|
||||
implementation project(":features:navigator")
|
||||
implementation project(":design-library")
|
||||
api project(":domains:android:core")
|
||||
api project(":domains:state")
|
||||
}
|
||||
|
@ -3,7 +3,11 @@ package app.dapk.st.core
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelLazy
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.*
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
inline fun <reified VM : ViewModel> ComponentActivity.viewModel(
|
||||
noinline factory: () -> VM
|
||||
@ -17,3 +21,53 @@ inline fun <reified VM : ViewModel> ComponentActivity.viewModel(
|
||||
}
|
||||
return ViewModelLazy(VM::class, { viewModelStore }, { factoryPromise })
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified S, E> ComponentActivity.state(
|
||||
noinline factory: () -> State<S, E>
|
||||
): Lazy<State<S, E>> {
|
||||
val factoryPromise = object : Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when(modelClass) {
|
||||
StateViewModel::class.java -> factory() as T
|
||||
else -> throw Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
return KeyedViewModelLazy(
|
||||
key = S::class.java.canonicalName!!,
|
||||
StateViewModel::class,
|
||||
{ viewModelStore },
|
||||
{ factoryPromise }
|
||||
) as Lazy<State<S, E>>
|
||||
}
|
||||
|
||||
class KeyedViewModelLazy<VM : ViewModel> @JvmOverloads constructor(
|
||||
private val key: String,
|
||||
private val viewModelClass: KClass<VM>,
|
||||
private val storeProducer: () -> ViewModelStore,
|
||||
private val factoryProducer: () -> ViewModelProvider.Factory,
|
||||
) : Lazy<VM> {
|
||||
private var cached: VM? = null
|
||||
|
||||
override val value: VM
|
||||
get() {
|
||||
val viewModel = cached
|
||||
return if (viewModel == null) {
|
||||
val factory = factoryProducer()
|
||||
val store = storeProducer()
|
||||
ViewModelProvider(
|
||||
store,
|
||||
factory,
|
||||
CreationExtras.Empty
|
||||
).get(key, viewModelClass.java).also {
|
||||
cached = it
|
||||
}
|
||||
} else {
|
||||
viewModel
|
||||
}
|
||||
}
|
||||
|
||||
override fun isInitialized(): Boolean = cached != null
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.state.Action
|
||||
import app.dapk.state.ReducerFactory
|
||||
import app.dapk.state.Store
|
||||
import app.dapk.state.createStore
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
class StateViewModel<S, E>(
|
||||
reducerFactory: ReducerFactory<S>,
|
||||
eventSource: MutableSharedFlow<E>,
|
||||
) : ViewModel(), State<S, E> {
|
||||
|
||||
private val store: Store<S> = createStore(reducerFactory, viewModelScope)
|
||||
override val events: SharedFlow<E> = eventSource
|
||||
override val current
|
||||
get() = _state!!
|
||||
private var _state: S by mutableStateOf(store.getState())
|
||||
|
||||
init {
|
||||
_state = store.getState()
|
||||
store.subscribe {
|
||||
_state = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatch(action: Action) {
|
||||
store.dispatch(action)
|
||||
}
|
||||
}
|
||||
|
||||
fun <S, E> createStateViewModel(block: (suspend (E) -> Unit) -> ReducerFactory<S>): StateViewModel<S, E> {
|
||||
val eventSource = MutableSharedFlow<E>(extraBufferCapacity = 1)
|
||||
val reducer = block { eventSource.emit(it) }
|
||||
return StateViewModel(reducer, eventSource)
|
||||
}
|
||||
|
||||
interface State<S, E> {
|
||||
fun dispatch(action: Action)
|
||||
val events: SharedFlow<E>
|
||||
val current: S
|
||||
}
|
@ -0,0 +1,95 @@
|
||||
package app.dapk.st.core.page
|
||||
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.state.*
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
sealed interface PageAction<out P> : Action {
|
||||
data class GoTo<P : Any>(val page: SpiderPage<P>) : PageAction<P>
|
||||
}
|
||||
|
||||
sealed interface PageStateChange : Action {
|
||||
data class ChangePage<P : Any>(val previous: SpiderPage<out P>, val newPage: SpiderPage<out P>) : PageAction<P>
|
||||
data class UpdatePage<P : Any>(val pageContent: P) : PageAction<P>
|
||||
}
|
||||
|
||||
data class PageContainer<P>(
|
||||
val page: SpiderPage<out P>
|
||||
)
|
||||
|
||||
interface PageReducerScope<P> {
|
||||
fun <PC : Any> withPageContent(page: KClass<PC>, block: PageDispatchScope<PC>.() -> Unit)
|
||||
fun rawPage(): SpiderPage<out P>
|
||||
}
|
||||
|
||||
interface PageDispatchScope<PC> {
|
||||
fun ReducerScope<*>.pageDispatch(action: PageAction<PC>)
|
||||
fun getPageState(): PC?
|
||||
}
|
||||
|
||||
fun <P : Any, S : Any> createPageReducer(
|
||||
initialPage: SpiderPage<out P>,
|
||||
factory: PageReducerScope<P>.() -> ReducerFactory<S>,
|
||||
): ReducerFactory<Combined2<PageContainer<P>, S>> = shareState {
|
||||
combineReducers(createPageReducer(initialPage), factory(pageReducerScope()))
|
||||
}
|
||||
|
||||
private fun <P : Any, S : Any> SharedStateScope<Combined2<PageContainer<P>, S>>.pageReducerScope() = object : PageReducerScope<P> {
|
||||
override fun <PC : Any> withPageContent(page: KClass<PC>, block: PageDispatchScope<PC>.() -> Unit) {
|
||||
val currentPage = getSharedState().state1.page.state
|
||||
if (currentPage::class == page) {
|
||||
val pageDispatchScope = object : PageDispatchScope<PC> {
|
||||
override fun ReducerScope<*>.pageDispatch(action: PageAction<PC>) {
|
||||
val currentPageGuard = getSharedState().state1.page.state
|
||||
if (currentPageGuard::class == page) {
|
||||
dispatch(action)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPageState() = getSharedState().state1.page.state as? PC
|
||||
}
|
||||
block(pageDispatchScope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun rawPage() = getSharedState().state1.page
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <P : Any> createPageReducer(
|
||||
initialPage: SpiderPage<out P>
|
||||
): ReducerFactory<PageContainer<P>> {
|
||||
return createReducer(
|
||||
initialState = PageContainer(
|
||||
page = initialPage
|
||||
),
|
||||
|
||||
async(PageAction.GoTo::class) { action ->
|
||||
val state = getState()
|
||||
if (state.page.state::class != action.page.state::class) {
|
||||
dispatch(PageStateChange.ChangePage(previous = state.page, newPage = action.page))
|
||||
} else {
|
||||
dispatch(PageStateChange.UpdatePage(action.page.state))
|
||||
}
|
||||
},
|
||||
|
||||
change(PageStateChange.ChangePage::class) { action, state ->
|
||||
state.copy(page = action.newPage as SpiderPage<out P>)
|
||||
},
|
||||
|
||||
change(PageStateChange.UpdatePage::class) { action, state ->
|
||||
val isSamePage = state.page.state::class == action.pageContent::class
|
||||
if (isSamePage) {
|
||||
val updatedPageContent = (state.page as SpiderPage<Any>).copy(state = action.pageContent)
|
||||
state.copy(page = updatedPageContent as SpiderPage<out P>)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified PC : Any> PageReducerScope<*>.withPageContext(crossinline block: PageDispatchScope<PC>.(PC) -> Unit) {
|
||||
withPageContent(PC::class) { getPageState()?.let { block(it) } }
|
||||
}
|
||||
|
@ -0,0 +1,30 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
|
||||
data class ContentResolverQuery(
|
||||
val uri: Uri,
|
||||
val projection: List<String>,
|
||||
val selection: String,
|
||||
val selectionArgs: List<String>,
|
||||
val sortBy: String,
|
||||
)
|
||||
|
||||
inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List<T> {
|
||||
return this.reduce(query, mutableListOf<T>()) { acc, cursor ->
|
||||
acc.add(operation(cursor))
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, initial: T, operation: (T, Cursor) -> T): T {
|
||||
var accumulator: T = initial
|
||||
this.query(query.uri, query.projection.toTypedArray(), query.selection, query.selectionArgs.toTypedArray(), query.sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
accumulator = operation(accumulator, cursor)
|
||||
}
|
||||
}
|
||||
return accumulator
|
||||
}
|
@ -10,4 +10,9 @@ dependencies {
|
||||
|
||||
implementation Dependencies.mavenCentral.kotlinSerializationJson
|
||||
implementation Dependencies.jitPack.unifiedPush
|
||||
|
||||
kotlinTest(it)
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
||||
import app.dapk.st.firebase.messaging.Messaging
|
||||
import app.dapk.st.push.messaging.MessagingPushTokenRegistrar
|
||||
import app.dapk.st.push.unifiedpush.UnifiedPushImpl
|
||||
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
|
||||
|
||||
class PushModule(
|
||||
@ -21,15 +22,15 @@ class PushModule(
|
||||
) : ProvidableModule {
|
||||
|
||||
private val registrars by unsafeLazy {
|
||||
val unifiedPush = UnifiedPushImpl(context)
|
||||
PushTokenRegistrars(
|
||||
context,
|
||||
MessagingPushTokenRegistrar(
|
||||
errorTracker,
|
||||
pushHandler,
|
||||
messaging,
|
||||
),
|
||||
UnifiedPushRegistrar(context),
|
||||
PushTokenRegistrarPreferences(preferences)
|
||||
UnifiedPushRegistrar(context, unifiedPush),
|
||||
PushTokenRegistrarPreferences(preferences),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,32 +1,30 @@
|
||||
package app.dapk.st.push
|
||||
|
||||
import android.content.Context
|
||||
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
||||
import app.dapk.st.push.messaging.MessagingPushTokenRegistrar
|
||||
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
private val FIREBASE_OPTION = Registrar("Google - Firebase (FCM)")
|
||||
private val NONE = Registrar("None")
|
||||
|
||||
class PushTokenRegistrars(
|
||||
private val context: Context,
|
||||
private val messagingPushTokenRegistrar: MessagingPushTokenRegistrar,
|
||||
private val unifiedPushRegistrar: UnifiedPushRegistrar,
|
||||
private val pushTokenStore: PushTokenRegistrarPreferences,
|
||||
private val state: SelectionState = SelectionState(selection = null),
|
||||
) : PushTokenRegistrar {
|
||||
|
||||
private var selection: Registrar? = null
|
||||
|
||||
fun options(): List<Registrar> {
|
||||
val messagingOption = when (messagingPushTokenRegistrar.isAvailable()) {
|
||||
true -> FIREBASE_OPTION
|
||||
else -> null
|
||||
}
|
||||
return listOfNotNull(NONE, messagingOption) + UnifiedPush.getDistributors(context).map { Registrar(it) }
|
||||
return listOfNotNull(NONE, messagingOption) + unifiedPushRegistrar.getDistributors()
|
||||
}
|
||||
|
||||
suspend fun currentSelection() = selection ?: (pushTokenStore.currentSelection()?.let { Registrar(it) } ?: defaultSelection()).also { selection = it }
|
||||
suspend fun currentSelection() = state.selection ?: (readStoredSelection() ?: defaultSelection()).also { state.selection = it }
|
||||
|
||||
private suspend fun readStoredSelection() = pushTokenStore.currentSelection()?.let { Registrar(it) }?.takeIf { options().contains(it) }
|
||||
|
||||
private fun defaultSelection() = when (messagingPushTokenRegistrar.isAvailable()) {
|
||||
true -> FIREBASE_OPTION
|
||||
@ -34,7 +32,7 @@ class PushTokenRegistrars(
|
||||
}
|
||||
|
||||
suspend fun makeSelection(option: Registrar) {
|
||||
selection = option
|
||||
state.selection = option
|
||||
pushTokenStore.store(option.id)
|
||||
when (option) {
|
||||
NONE -> {
|
||||
@ -66,7 +64,7 @@ class PushTokenRegistrars(
|
||||
}
|
||||
|
||||
override fun unregister() {
|
||||
when (selection) {
|
||||
when (state.selection) {
|
||||
FIREBASE_OPTION -> messagingPushTokenRegistrar.unregister()
|
||||
NONE -> {
|
||||
runCatching {
|
||||
@ -86,4 +84,6 @@ class PushTokenRegistrars(
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
value class Registrar(val id: String)
|
||||
value class Registrar(val id: String)
|
||||
|
||||
data class SelectionState(var selection: Registrar?)
|
||||
|
@ -0,0 +1,20 @@
|
||||
package app.dapk.st.push.unifiedpush
|
||||
|
||||
import android.content.Context
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
interface UnifiedPush {
|
||||
fun saveDistributor(distributor: String)
|
||||
fun getDistributor(): String
|
||||
fun getDistributors(): List<String>
|
||||
fun registerApp()
|
||||
fun unregisterApp()
|
||||
}
|
||||
|
||||
internal class UnifiedPushImpl(private val context: Context) : app.dapk.st.push.unifiedpush.UnifiedPush {
|
||||
override fun saveDistributor(distributor: String) = UnifiedPush.saveDistributor(context, distributor)
|
||||
override fun getDistributor(): String = UnifiedPush.getDistributor(context)
|
||||
override fun getDistributors(): List<String> = UnifiedPush.getDistributors(context)
|
||||
override fun registerApp() = UnifiedPush.registerApp(context)
|
||||
override fun unregisterApp() = UnifiedPush.unregisterApp(context)
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
package app.dapk.st.push.unifiedpush
|
||||
|
||||
import android.content.Context
|
||||
import app.dapk.st.core.AppLogTag
|
||||
import app.dapk.st.core.log
|
||||
import app.dapk.st.core.module
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.push.PushModule
|
||||
import app.dapk.st.push.PushTokenPayload
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URL
|
||||
|
||||
private const val FALLBACK_UNIFIED_PUSH_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
class UnifiedPushMessageDelegate(
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob()),
|
||||
private val pushModuleProvider: (Context) -> PushModule = { it.module() },
|
||||
private val endpointReader: suspend (URL) -> String = {
|
||||
runCatching { it.openStream().use { String(it.readBytes()) } }.getOrNull() ?: ""
|
||||
}
|
||||
) {
|
||||
|
||||
fun onMessage(context: Context, message: ByteArray) {
|
||||
log(AppLogTag.PUSH, "UnifiedPush onMessage, $message")
|
||||
val module = pushModuleProvider(context)
|
||||
val handler = module.pushHandler()
|
||||
scope.launch {
|
||||
withContext(module.dispatcher().io) {
|
||||
val payload = json.decodeFromString(UnifiedPushMessagePayload.serializer(), String(message))
|
||||
handler.onMessageReceived(payload.notification.eventId?.let { EventId(it) }, payload.notification.roomId?.let { RoomId(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNewEndpoint(context: Context, endpoint: String) {
|
||||
log(AppLogTag.PUSH, "UnifiedPush onNewEndpoint $endpoint")
|
||||
val module = pushModuleProvider(context)
|
||||
val handler = module.pushHandler()
|
||||
scope.launch {
|
||||
withContext(module.dispatcher().io) {
|
||||
val matrixEndpoint = URL(endpoint).let { URL("${it.protocol}://${it.host}/_matrix/push/v1/notify") }
|
||||
val content = endpointReader(matrixEndpoint)
|
||||
val gatewayUrl = when {
|
||||
content.contains("\"gateway\":\"matrix\"") -> matrixEndpoint.toString()
|
||||
else -> FALLBACK_UNIFIED_PUSH_GATEWAY
|
||||
}
|
||||
handler.onNewToken(PushTokenPayload(token = endpoint, gatewayUrl = gatewayUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class UnifiedPushMessagePayload(
|
||||
@SerialName("notification") val notification: Notification,
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Notification(
|
||||
@SerialName("event_id") val eventId: String? = null,
|
||||
@SerialName("room_id") val roomId: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -3,56 +3,19 @@ package app.dapk.st.push.unifiedpush
|
||||
import android.content.Context
|
||||
import app.dapk.st.core.AppLogTag
|
||||
import app.dapk.st.core.log
|
||||
import app.dapk.st.core.module
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.push.PushModule
|
||||
import app.dapk.st.push.PushTokenPayload
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
import java.net.URL
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private const val FALLBACK_UNIFIED_PUSH_GATEWAY = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
|
||||
|
||||
class UnifiedPushMessageReceiver : MessagingReceiver() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob())
|
||||
private val delegate = UnifiedPushMessageDelegate()
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
log(AppLogTag.PUSH, "UnifiedPush onMessage, $message")
|
||||
val module = context.module<PushModule>()
|
||||
val handler = module.pushHandler()
|
||||
scope.launch {
|
||||
withContext(module.dispatcher().io) {
|
||||
val payload = json.decodeFromString(UnifiedPushMessagePayload.serializer(), String(message))
|
||||
handler.onMessageReceived(payload.notification.eventId?.let { EventId(it) }, payload.notification.roomId?.let { RoomId(it) })
|
||||
}
|
||||
}
|
||||
delegate.onMessage(context, message)
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
log(AppLogTag.PUSH, "UnifiedPush onNewEndpoint $endpoint")
|
||||
val module = context.module<PushModule>()
|
||||
val handler = module.pushHandler()
|
||||
scope.launch {
|
||||
withContext(module.dispatcher().io) {
|
||||
val matrixEndpoint = URL(endpoint).let { URL("${it.protocol}://${it.host}/_matrix/push/v1/notify") }
|
||||
val content = runCatching { matrixEndpoint.openStream().use { String(it.readBytes()) } }.getOrNull() ?: ""
|
||||
val gatewayUrl = when {
|
||||
content.contains("\"gateway\":\"matrix\"") -> matrixEndpoint.toString()
|
||||
else -> FALLBACK_UNIFIED_PUSH_GATEWAY
|
||||
}
|
||||
handler.onNewToken(PushTokenPayload(token = endpoint, gatewayUrl = gatewayUrl))
|
||||
}
|
||||
}
|
||||
delegate.onNewEndpoint(context, endpoint)
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(context: Context, instance: String) {
|
||||
@ -63,15 +26,4 @@ class UnifiedPushMessageReceiver : MessagingReceiver() {
|
||||
log(AppLogTag.PUSH, "UnifiedPush onUnregistered")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class UnifiedPushMessagePayload(
|
||||
@SerialName("notification") val notification: Notification,
|
||||
) {
|
||||
|
||||
@Serializable
|
||||
data class Notification(
|
||||
@SerialName("event_id") val eventId: String? = null,
|
||||
@SerialName("room_id") val roomId: String? = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,38 +7,41 @@ import app.dapk.st.core.AppLogTag
|
||||
import app.dapk.st.core.log
|
||||
import app.dapk.st.push.PushTokenRegistrar
|
||||
import app.dapk.st.push.Registrar
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
class UnifiedPushRegistrar(
|
||||
private val context: Context,
|
||||
private val unifiedPush: UnifiedPush,
|
||||
private val componentFactory: (Context) -> ComponentName = { ComponentName(it, UnifiedPushMessageReceiver::class.java) }
|
||||
) : PushTokenRegistrar {
|
||||
|
||||
fun getDistributors() = unifiedPush.getDistributors().map { Registrar(it) }
|
||||
|
||||
fun registerSelection(registrar: Registrar) {
|
||||
log(AppLogTag.PUSH, "UnifiedPush - register: $registrar")
|
||||
UnifiedPush.saveDistributor(context, registrar.id)
|
||||
unifiedPush.saveDistributor(registrar.id)
|
||||
registerApp()
|
||||
}
|
||||
|
||||
override suspend fun registerCurrentToken() {
|
||||
log(AppLogTag.PUSH, "UnifiedPush - register current token")
|
||||
if (UnifiedPush.getDistributor(context).isNotEmpty()) {
|
||||
if (unifiedPush.getDistributor().isNotEmpty()) {
|
||||
registerApp()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerApp() {
|
||||
context.packageManager.setComponentEnabledSetting(
|
||||
ComponentName(context, UnifiedPushMessageReceiver::class.java),
|
||||
componentFactory(context),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
UnifiedPush.registerApp(context)
|
||||
unifiedPush.registerApp()
|
||||
}
|
||||
|
||||
override fun unregister() {
|
||||
UnifiedPush.unregisterApp(context)
|
||||
unifiedPush.unregisterApp()
|
||||
context.packageManager.setComponentEnabledSetting(
|
||||
ComponentName(context, UnifiedPushMessageReceiver::class.java),
|
||||
componentFactory(context),
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP,
|
||||
)
|
||||
|
@ -0,0 +1,205 @@
|
||||
package app.dapk.st.push
|
||||
|
||||
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
||||
import app.dapk.st.push.messaging.MessagingPushTokenRegistrar
|
||||
import app.dapk.st.push.unifiedpush.UnifiedPushRegistrar
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
import test.delegateReturn
|
||||
import test.runExpectTest
|
||||
|
||||
private val UNIFIED_PUSH = Registrar("unified-push option")
|
||||
private val NONE = Registrar("None")
|
||||
private val FIREBASE = Registrar("Google - Firebase (FCM)")
|
||||
private val UNIFIED_PUSH_DISTRIBUTORS = listOf(UNIFIED_PUSH)
|
||||
|
||||
class PushTokenRegistrarsTest {
|
||||
|
||||
private val fakeMessagingPushRegistrar = FakeMessagingPushRegistrar()
|
||||
private val fakeUnifiedPushRegistrar = FakeUnifiedPushRegistrar()
|
||||
private val fakePushTokenRegistrarPreferences = FakePushTokenRegistrarPreferences()
|
||||
private val selectionState = SelectionState(selection = null)
|
||||
|
||||
private val registrars = PushTokenRegistrars(
|
||||
fakeMessagingPushRegistrar.instance,
|
||||
fakeUnifiedPushRegistrar.instance,
|
||||
fakePushTokenRegistrarPreferences.instance,
|
||||
selectionState,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `given messaging is available, when reading options, then returns firebase and unified push`() {
|
||||
fakeMessagingPushRegistrar.givenIsAvailable().returns(true)
|
||||
fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS)
|
||||
|
||||
val result = registrars.options()
|
||||
|
||||
result shouldBeEqualTo listOf(Registrar("None"), FIREBASE) + UNIFIED_PUSH_DISTRIBUTORS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given messaging is not available, when reading options, then returns unified push`() {
|
||||
fakeMessagingPushRegistrar.givenIsAvailable().returns(false)
|
||||
fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS)
|
||||
|
||||
val result = registrars.options()
|
||||
|
||||
result shouldBeEqualTo listOf(Registrar("None")) + UNIFIED_PUSH_DISTRIBUTORS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given no saved selection and messaging is not available, when reading default selection, then returns none`() = runTest {
|
||||
fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(null)
|
||||
fakeMessagingPushRegistrar.givenIsAvailable().returns(false)
|
||||
|
||||
val result = registrars.currentSelection()
|
||||
|
||||
result shouldBeEqualTo NONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given no saved selection and messaging is available, when reading default selection, then returns firebase`() = runTest {
|
||||
fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(null)
|
||||
fakeMessagingPushRegistrar.givenIsAvailable().returns(true)
|
||||
|
||||
val result = registrars.currentSelection()
|
||||
|
||||
result shouldBeEqualTo FIREBASE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given saved selection and is a option, when reading default selection, then returns selection`() = runTest {
|
||||
fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS)
|
||||
fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(FIREBASE.id)
|
||||
fakeMessagingPushRegistrar.givenIsAvailable().returns(true)
|
||||
|
||||
val result = registrars.currentSelection()
|
||||
|
||||
result shouldBeEqualTo FIREBASE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given saved selection and is not an option, when reading default selection, then returns next default`() = runTest {
|
||||
fakeUnifiedPushRegistrar.givenDistributors().returns(UNIFIED_PUSH_DISTRIBUTORS)
|
||||
fakePushTokenRegistrarPreferences.givenCurrentSelection().returns(FIREBASE.id)
|
||||
fakeMessagingPushRegistrar.givenIsAvailable().returns(false)
|
||||
|
||||
val result = registrars.currentSelection()
|
||||
|
||||
result shouldBeEqualTo NONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when selecting none, then stores and unregisters`() = runExpectTest {
|
||||
fakePushTokenRegistrarPreferences.instance.expect { it.store(NONE.id) }
|
||||
fakeMessagingPushRegistrar.instance.expect { it.unregister() }
|
||||
fakeUnifiedPushRegistrar.instance.expect { it.unregister() }
|
||||
|
||||
registrars.makeSelection(NONE)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when selecting firebase, then stores and unregisters unifiedpush`() = runExpectTest {
|
||||
fakePushTokenRegistrarPreferences.instance.expect { it.store(FIREBASE.id) }
|
||||
fakeMessagingPushRegistrar.instance.expect { it.registerCurrentToken() }
|
||||
fakeUnifiedPushRegistrar.instance.expect { it.unregister() }
|
||||
|
||||
registrars.makeSelection(FIREBASE)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when selecting unified push, then stores and unregisters firebase`() = runExpectTest {
|
||||
fakePushTokenRegistrarPreferences.instance.expect { it.store(UNIFIED_PUSH.id) }
|
||||
fakeMessagingPushRegistrar.instance.expect { it.unregister() }
|
||||
fakeUnifiedPushRegistrar.instance.expect { it.registerSelection(UNIFIED_PUSH) }
|
||||
|
||||
registrars.makeSelection(UNIFIED_PUSH)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given unified push selected, when registering current token, then delegates`() = runExpectTest {
|
||||
selectionState.selection = UNIFIED_PUSH
|
||||
fakeUnifiedPushRegistrar.instance.expect { it.registerCurrentToken() }
|
||||
|
||||
registrars.registerCurrentToken()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given firebase selected, when registering current token, then delegates`() = runExpectTest {
|
||||
selectionState.selection = FIREBASE
|
||||
fakeMessagingPushRegistrar.instance.expect { it.registerCurrentToken() }
|
||||
|
||||
registrars.registerCurrentToken()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given none selected, when registering current token, then does nothing`() = runExpectTest {
|
||||
selectionState.selection = NONE
|
||||
|
||||
registrars.registerCurrentToken()
|
||||
|
||||
verify { fakeMessagingPushRegistrar.instance wasNot Called }
|
||||
verify { fakeUnifiedPushRegistrar.instance wasNot Called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given unified push selected, when unregistering, then delegates`() = runExpectTest {
|
||||
selectionState.selection = UNIFIED_PUSH
|
||||
fakeUnifiedPushRegistrar.instance.expect { it.unregister() }
|
||||
|
||||
registrars.unregister()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given firebase selected, when unregistering, then delegates`() = runExpectTest {
|
||||
selectionState.selection = FIREBASE
|
||||
fakeMessagingPushRegistrar.instance.expect { it.unregister() }
|
||||
|
||||
registrars.unregister()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given none selected, when unregistering, then unregisters all`() = runExpectTest {
|
||||
selectionState.selection = NONE
|
||||
fakeUnifiedPushRegistrar.instance.expect { it.unregister() }
|
||||
fakeMessagingPushRegistrar.instance.expect { it.unregister() }
|
||||
|
||||
registrars.unregister()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMessagingPushRegistrar {
|
||||
val instance = mockk<MessagingPushTokenRegistrar>()
|
||||
|
||||
fun givenIsAvailable() = every { instance.isAvailable() }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeUnifiedPushRegistrar {
|
||||
val instance = mockk<UnifiedPushRegistrar>()
|
||||
|
||||
fun givenDistributors() = every { instance.getDistributors() }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakePushTokenRegistrarPreferences {
|
||||
val instance = mockk<PushTokenRegistrarPreferences>()
|
||||
|
||||
fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn()
|
||||
}
|
@ -0,0 +1,79 @@
|
||||
package app.dapk.st.push.messaging
|
||||
|
||||
import app.dapk.st.firebase.messaging.Messaging
|
||||
import app.dapk.st.push.PushTokenPayload
|
||||
import app.dapk.st.push.unifiedpush.FakePushHandler
|
||||
import fake.FakeErrorTracker
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
import test.delegateReturn
|
||||
import test.runExpectTest
|
||||
|
||||
private const val A_TOKEN = "a-token"
|
||||
private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify"
|
||||
private val AN_ERROR = RuntimeException()
|
||||
|
||||
class MessagingPushTokenRegistrarTest {
|
||||
|
||||
private val fakePushHandler = FakePushHandler()
|
||||
private val fakeErrorTracker = FakeErrorTracker()
|
||||
private val fakeMessaging = FakeMessaging()
|
||||
|
||||
private val registrar = MessagingPushTokenRegistrar(
|
||||
fakeErrorTracker,
|
||||
fakePushHandler,
|
||||
fakeMessaging.instance,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when checking isAvailable, then delegates`() = runExpectTest {
|
||||
fakeMessaging.givenIsAvailable().returns(true)
|
||||
|
||||
val result = registrar.isAvailable()
|
||||
|
||||
result shouldBeEqualTo true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when registering current token, then enables and forwards current token to handler`() = runExpectTest {
|
||||
fakeMessaging.instance.expect { it.enable() }
|
||||
fakePushHandler.expect { it.onNewToken(PushTokenPayload(A_TOKEN, SYGNAL_GATEWAY)) }
|
||||
fakeMessaging.givenToken().returns(A_TOKEN)
|
||||
|
||||
registrar.registerCurrentToken()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given fails to register, when registering current token, then tracks error`() = runExpectTest {
|
||||
fakeMessaging.instance.expect { it.enable() }
|
||||
fakeMessaging.givenToken().throws(AN_ERROR)
|
||||
fakeErrorTracker.expect { it.track(AN_ERROR) }
|
||||
|
||||
registrar.registerCurrentToken()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when unregistering, then deletes token and disables`() = runExpectTest {
|
||||
fakeMessaging.instance.expect { it.deleteToken() }
|
||||
fakeMessaging.instance.expect { it.disable() }
|
||||
|
||||
registrar.unregister()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class FakeMessaging {
|
||||
val instance = mockk<Messaging>()
|
||||
|
||||
fun givenIsAvailable() = every { instance.isAvailable() }.delegateReturn()
|
||||
fun givenToken() = coEvery { instance.token() }.delegateReturn()
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package app.dapk.st.push.messaging
|
||||
|
||||
import app.dapk.st.push.PushTokenPayload
|
||||
import app.dapk.st.push.unifiedpush.FakePushHandler
|
||||
import fixture.aRoomId
|
||||
import fixture.anEventId
|
||||
import org.junit.Test
|
||||
import test.runExpectTest
|
||||
|
||||
private const val A_TOKEN = "a-push-token"
|
||||
private const val SYGNAL_GATEWAY = "https://sygnal.dapk.app/_matrix/push/v1/notify"
|
||||
private val A_ROOM_ID = aRoomId()
|
||||
private val AN_EVENT_ID = anEventId()
|
||||
|
||||
class MessagingServiceAdapterTest {
|
||||
|
||||
private val fakePushHandler = FakePushHandler()
|
||||
|
||||
private val messagingServiceAdapter = MessagingServiceAdapter(fakePushHandler)
|
||||
|
||||
@Test
|
||||
fun `onNewToken, then delegates to push handler`() = runExpectTest {
|
||||
fakePushHandler.expect {
|
||||
it.onNewToken(PushTokenPayload(token = A_TOKEN, gatewayUrl = SYGNAL_GATEWAY))
|
||||
}
|
||||
messagingServiceAdapter.onNewToken(A_TOKEN)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `onMessageReceived, then delegates to push handler`() = runExpectTest {
|
||||
fakePushHandler.expect {
|
||||
it.onMessageReceived(AN_EVENT_ID, A_ROOM_ID)
|
||||
}
|
||||
messagingServiceAdapter.onMessageReceived(AN_EVENT_ID, A_ROOM_ID)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package app.dapk.st.push.unifiedpush
|
||||
|
||||
import app.dapk.st.push.PushHandler
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakePushHandler : PushHandler by mockk()
|
@ -0,0 +1,95 @@
|
||||
package app.dapk.st.push.unifiedpush
|
||||
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.push.PushModule
|
||||
import app.dapk.st.push.PushTokenPayload
|
||||
import fake.FakeContext
|
||||
import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import org.junit.Test
|
||||
import test.delegateReturn
|
||||
import test.runExpectTest
|
||||
import java.net.URL
|
||||
|
||||
private val A_CONTEXT = FakeContext()
|
||||
private const val A_ROOM_ID = "a room id"
|
||||
private const val AN_EVENT_ID = "an event id"
|
||||
private const val AN_ENDPOINT_HOST = "https://aendpointurl.com"
|
||||
private const val AN_ENDPOINT = "$AN_ENDPOINT_HOST/with/path"
|
||||
private const val A_GATEWAY_URL = "$AN_ENDPOINT_HOST/_matrix/push/v1/notify"
|
||||
private const val FALLBACK_GATEWAY_URL = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify"
|
||||
|
||||
class UnifiedPushMessageDelegateTest {
|
||||
|
||||
private val fakePushHandler = FakePushHandler()
|
||||
private val fakeEndpointReader = FakeEndpointReader()
|
||||
private val fakePushModule = FakePushModule().also {
|
||||
it.givenPushHandler().returns(fakePushHandler)
|
||||
}
|
||||
|
||||
private val unifiedPushReceiver = UnifiedPushMessageDelegate(
|
||||
CoroutineScope(UnconfinedTestDispatcher()),
|
||||
pushModuleProvider = { _ -> fakePushModule.instance },
|
||||
endpointReader = fakeEndpointReader,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `parses incoming message payloads`() = runExpectTest {
|
||||
fakePushHandler.expect { it.onMessageReceived(EventId(AN_EVENT_ID), RoomId(A_ROOM_ID)) }
|
||||
val messageBytes = createMessage(A_ROOM_ID, AN_EVENT_ID)
|
||||
|
||||
unifiedPushReceiver.onMessage(A_CONTEXT.instance, messageBytes)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given endpoint is a gateway, then uses original endpoint url`() = runExpectTest {
|
||||
fakeEndpointReader.given(A_GATEWAY_URL).returns("""{"unifiedpush":{"gateway":"matrix"}}""")
|
||||
fakePushHandler.expect { it.onNewToken(PushTokenPayload(token = AN_ENDPOINT, gatewayUrl = A_GATEWAY_URL)) }
|
||||
|
||||
unifiedPushReceiver.onNewEndpoint(A_CONTEXT.instance, AN_ENDPOINT)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given endpoint is not a gateway, then uses fallback endpoint url`() = runExpectTest {
|
||||
fakeEndpointReader.given(A_GATEWAY_URL).returns("")
|
||||
fakePushHandler.expect { it.onNewToken(PushTokenPayload(token = AN_ENDPOINT, gatewayUrl = FALLBACK_GATEWAY_URL)) }
|
||||
|
||||
unifiedPushReceiver.onNewEndpoint(A_CONTEXT.instance, AN_ENDPOINT)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
private fun createMessage(roomId: String, eventId: String) = """
|
||||
{
|
||||
"notification": {
|
||||
"room_id": "$roomId",
|
||||
"event_id": "$eventId"
|
||||
}
|
||||
}
|
||||
""".trimIndent().toByteArray()
|
||||
}
|
||||
|
||||
class FakePushModule {
|
||||
val instance = mockk<PushModule>()
|
||||
|
||||
init {
|
||||
every { instance.dispatcher() }.returns(aCoroutineDispatchers())
|
||||
}
|
||||
|
||||
fun givenPushHandler() = every { instance.pushHandler() }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeEndpointReader : suspend (URL) -> String by mockk() {
|
||||
|
||||
fun given(url: String) = coEvery { this@FakeEndpointReader.invoke(URL(url)) }.delegateReturn()
|
||||
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
package app.dapk.st.push.unifiedpush
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import app.dapk.st.push.Registrar
|
||||
import fake.FakeContext
|
||||
import fake.FakePackageManager
|
||||
import io.mockk.Called
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
import test.delegateReturn
|
||||
import test.runExpectTest
|
||||
|
||||
private val A_COMPONENT_NAME = FakeComponentName()
|
||||
private val A_REGISTRAR_SELECTION = Registrar("a-registrar")
|
||||
private const val A_SAVED_DISTRIBUTOR = "a distributor"
|
||||
|
||||
class UnifiedPushRegistrarTest {
|
||||
|
||||
private val fakePackageManager = FakePackageManager()
|
||||
private val fakeContext = FakeContext().also {
|
||||
it.givenPackageManager().returns(fakePackageManager.instance)
|
||||
}
|
||||
private val fakeUnifiedPush = FakeUnifiedPush()
|
||||
private val fakeComponentFactory = { _: Context -> A_COMPONENT_NAME.instance }
|
||||
|
||||
private val registrar = UnifiedPushRegistrar(fakeContext.instance, fakeUnifiedPush, fakeComponentFactory)
|
||||
|
||||
@Test
|
||||
fun `when unregistering, then updates unified push and disables component`() = runExpectTest {
|
||||
fakeUnifiedPush.expect { it.unregisterApp() }
|
||||
fakePackageManager.instance.expect {
|
||||
it.setComponentEnabledSetting(A_COMPONENT_NAME.instance, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
|
||||
registrar.unregister()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when registering selection, then updates unified push and enables component`() = runExpectTest {
|
||||
fakeUnifiedPush.expect { it.registerApp() }
|
||||
fakeUnifiedPush.expect { it.saveDistributor(A_REGISTRAR_SELECTION.id) }
|
||||
fakePackageManager.instance.expect {
|
||||
it.setComponentEnabledSetting(A_COMPONENT_NAME.instance, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
|
||||
registrar.registerSelection(A_REGISTRAR_SELECTION)
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given saved distributor, when registering current token, then updates unified push and enables component`() = runExpectTest {
|
||||
fakeUnifiedPush.givenDistributor().returns(A_SAVED_DISTRIBUTOR)
|
||||
fakeUnifiedPush.expect { it.registerApp() }
|
||||
fakePackageManager.instance.expect {
|
||||
it.setComponentEnabledSetting(A_COMPONENT_NAME.instance, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
|
||||
registrar.registerCurrentToken()
|
||||
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given no distributor, when registering current token, then does nothing`() = runExpectTest {
|
||||
fakeUnifiedPush.givenDistributor().returns("")
|
||||
|
||||
registrar.registerCurrentToken()
|
||||
|
||||
verify(exactly = 0) { fakeUnifiedPush.registerApp() }
|
||||
verify { fakePackageManager.instance wasNot Called }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given distributors, then returns them as Registrars`() {
|
||||
fakeUnifiedPush.givenDistributors().returns(listOf("a", "b"))
|
||||
|
||||
val result = registrar.getDistributors()
|
||||
|
||||
result shouldBeEqualTo listOf(Registrar("a"), Registrar("b"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FakeUnifiedPush : UnifiedPush by mockk() {
|
||||
fun givenDistributor() = every { getDistributor() }.delegateReturn()
|
||||
fun givenDistributors() = every { getDistributors() }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeComponentName {
|
||||
val instance = mockk<ComponentName>()
|
||||
}
|
@ -13,4 +13,20 @@ class FakeContentResolver {
|
||||
fun givenFile(uri: Uri) = every { instance.openInputStream(uri) }.delegateReturn()
|
||||
|
||||
fun givenUriResult(uri: Uri) = every { instance.query(uri, null, null, null, null) }.delegateReturn()
|
||||
|
||||
fun givenQueryResult(
|
||||
uri: Uri,
|
||||
projection: Array<String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
sortOrder: String?,
|
||||
) = every {
|
||||
instance.query(
|
||||
uri,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder
|
||||
)
|
||||
}.delegateReturn()
|
||||
}
|
||||
|
@ -1,8 +1,16 @@
|
||||
package fake
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import test.delegateReturn
|
||||
|
||||
class FakeContext {
|
||||
val instance = mockk<Context>()
|
||||
fun givenPackageManager() = every { instance.packageManager }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakePackageManager {
|
||||
val instance = mockk<PackageManager>()
|
||||
}
|
@ -24,4 +24,56 @@ class FakeCursor {
|
||||
every { instance.getColumnIndex(columnName) } returns columnId
|
||||
every { instance.getString(columnId) } returns content
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface CreateCursorScope {
|
||||
fun addRow(vararg item: Pair<String, Any?>)
|
||||
}
|
||||
|
||||
fun createCursor(creator: CreateCursorScope.() -> Unit): Cursor {
|
||||
val content = mutableListOf<Map<String, Any?>>()
|
||||
val scope = object : CreateCursorScope {
|
||||
override fun addRow(vararg item: Pair<String, Any?>) {
|
||||
content.add(item.toMap())
|
||||
}
|
||||
}
|
||||
creator(scope)
|
||||
return StubCursor(content)
|
||||
}
|
||||
|
||||
private class StubCursor(private val content: List<Map<String, Any?>>) : Cursor by mockk() {
|
||||
|
||||
private val columnNames = content.map { it.keys }.flatten().distinct()
|
||||
private var currentRowIndex = -1
|
||||
|
||||
override fun getColumnIndexOrThrow(columnName: String): Int {
|
||||
return getColumnIndex(columnName).takeIf { it != -1 } ?: throw IllegalArgumentException(columnName)
|
||||
}
|
||||
|
||||
override fun getColumnIndex(columnName: String) = columnNames.indexOf(columnName)
|
||||
|
||||
override fun moveToNext() = (currentRowIndex + 1 < content.size).also {
|
||||
currentRowIndex += 1
|
||||
}
|
||||
|
||||
override fun moveToFirst() = content.isNotEmpty()
|
||||
|
||||
override fun getCount() = content.size
|
||||
|
||||
override fun getString(index: Int): String? = content[currentRowIndex][columnNames[index]] as? String
|
||||
|
||||
override fun getInt(index: Int): Int {
|
||||
return content[currentRowIndex][columnNames[index]] as? Int ?: throw IllegalArgumentException("Int can't be null")
|
||||
}
|
||||
|
||||
override fun getLong(index: Int): Long {
|
||||
return content[currentRowIndex][columnNames[index]] as? Long ?: throw IllegalArgumentException("Long can't be null")
|
||||
}
|
||||
|
||||
override fun getColumnCount() = columnNames.size
|
||||
|
||||
override fun close() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
@ -25,4 +25,3 @@ abstract class DapkViewModel<S, VE>(initialState: S, factory: MutableStateFactor
|
||||
state = reducer(state)
|
||||
}
|
||||
}
|
||||
|
||||
|
14
domains/state/build.gradle
Normal file
14
domains/state/build.gradle
Normal file
@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
id 'kotlin'
|
||||
id 'java-test-fixtures'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
|
||||
testFixturesImplementation testFixtures(project(":core"))
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
testFixturesImplementation Dependencies.mavenCentral.kluent
|
||||
testFixturesImplementation Dependencies.mavenCentral.mockk
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
|
||||
}
|
194
domains/state/src/main/kotlin/app/dapk/state/State.kt
Normal file
194
domains/state/src/main/kotlin/app/dapk/state/State.kt
Normal file
@ -0,0 +1,194 @@
|
||||
package app.dapk.state
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun <S> createStore(reducerFactory: ReducerFactory<S>, coroutineScope: CoroutineScope): Store<S> {
|
||||
val subscribers = mutableListOf<(S) -> Unit>()
|
||||
var state: S = reducerFactory.initialState()
|
||||
return object : Store<S> {
|
||||
private val scope = createScope(coroutineScope, this)
|
||||
private val reducer = reducerFactory.create(scope)
|
||||
|
||||
override fun dispatch(action: Action) {
|
||||
coroutineScope.launch {
|
||||
state = reducer.reduce(action).also { nextState ->
|
||||
if (nextState != state) {
|
||||
subscribers.forEach { it.invoke(nextState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState() = state
|
||||
|
||||
override fun subscribe(subscriber: (S) -> Unit) {
|
||||
subscribers.add(subscriber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ReducerFactory<S> {
|
||||
fun create(scope: ReducerScope<S>): Reducer<S>
|
||||
fun initialState(): S
|
||||
}
|
||||
|
||||
fun interface Reducer<S> {
|
||||
fun reduce(action: Action): S
|
||||
}
|
||||
|
||||
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
|
||||
override val coroutineScope = coroutineScope
|
||||
override fun dispatch(action: Action) = store.dispatch(action)
|
||||
override fun getState(): S = store.getState()
|
||||
}
|
||||
|
||||
interface Store<S> {
|
||||
fun dispatch(action: Action)
|
||||
fun getState(): S
|
||||
fun subscribe(subscriber: (S) -> Unit)
|
||||
}
|
||||
|
||||
interface ReducerScope<S> {
|
||||
val coroutineScope: CoroutineScope
|
||||
fun dispatch(action: Action)
|
||||
fun getState(): S
|
||||
}
|
||||
|
||||
sealed interface ActionHandler<S> {
|
||||
val key: KClass<Action>
|
||||
|
||||
class Async<S>(override val key: KClass<Action>, val handler: suspend ReducerScope<S>.(Action) -> Unit) : ActionHandler<S>
|
||||
class Sync<S>(override val key: KClass<Action>, val handler: (Action, S) -> S) : ActionHandler<S>
|
||||
class Delegate<S>(override val key: KClass<Action>, val handler: ReducerScope<S>.(Action) -> ActionHandler<S>) : ActionHandler<S>
|
||||
}
|
||||
|
||||
data class Combined2<S1, S2>(val state1: S1, val state2: S2)
|
||||
|
||||
fun interface SharedStateScope<C> {
|
||||
fun getSharedState(): C
|
||||
}
|
||||
|
||||
fun <S> shareState(block: SharedStateScope<S>.() -> ReducerFactory<S>): ReducerFactory<S> {
|
||||
var internalScope: ReducerScope<S>? = null
|
||||
val scope = SharedStateScope { internalScope!!.getState() }
|
||||
val combinedFactory = block(scope)
|
||||
return object : ReducerFactory<S> {
|
||||
override fun create(scope: ReducerScope<S>) = combinedFactory.create(scope).also { internalScope = scope }
|
||||
override fun initialState() = combinedFactory.initialState()
|
||||
}
|
||||
}
|
||||
|
||||
fun <S1, S2> combineReducers(r1: ReducerFactory<S1>, r2: ReducerFactory<S2>): ReducerFactory<Combined2<S1, S2>> {
|
||||
return object : ReducerFactory<Combined2<S1, S2>> {
|
||||
override fun create(scope: ReducerScope<Combined2<S1, S2>>): Reducer<Combined2<S1, S2>> {
|
||||
val r1Scope = createReducerScope(scope) { scope.getState().state1 }
|
||||
val r2Scope = createReducerScope(scope) { scope.getState().state2 }
|
||||
|
||||
val r1Reducer = r1.create(r1Scope)
|
||||
val r2Reducer = r2.create(r2Scope)
|
||||
return Reducer {
|
||||
Combined2(r1Reducer.reduce(it), r2Reducer.reduce(it))
|
||||
}
|
||||
}
|
||||
|
||||
override fun initialState(): Combined2<S1, S2> = Combined2(r1.initialState(), r2.initialState())
|
||||
}
|
||||
}
|
||||
|
||||
private fun <S> createReducerScope(scope: ReducerScope<*>, state: () -> S) = object : ReducerScope<S> {
|
||||
override val coroutineScope: CoroutineScope = scope.coroutineScope
|
||||
override fun dispatch(action: Action) = scope.dispatch(action)
|
||||
override fun getState() = state.invoke()
|
||||
}
|
||||
|
||||
fun <S> createReducer(
|
||||
initialState: S,
|
||||
vararg reducers: (ReducerScope<S>) -> ActionHandler<S>,
|
||||
): ReducerFactory<S> {
|
||||
return object : ReducerFactory<S> {
|
||||
override fun create(scope: ReducerScope<S>): Reducer<S> {
|
||||
val reducersMap = reducers
|
||||
.map { it.invoke(scope) }
|
||||
.groupBy { it.key }
|
||||
|
||||
return Reducer { action ->
|
||||
val result = reducersMap.keys
|
||||
.filter { it.java.isAssignableFrom(action::class.java) }
|
||||
.fold(scope.getState()) { acc, key ->
|
||||
val actionHandlers = reducersMap[key]!!
|
||||
actionHandlers.fold(acc) { acc, handler ->
|
||||
when (handler) {
|
||||
is ActionHandler.Async -> {
|
||||
scope.coroutineScope.launch {
|
||||
handler.handler.invoke(scope, action)
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
is ActionHandler.Sync -> handler.handler.invoke(action, acc)
|
||||
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
|
||||
is ActionHandler.Async -> {
|
||||
scope.coroutineScope.launch {
|
||||
next.handler.invoke(scope, action)
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
is ActionHandler.Sync -> next.handler.invoke(action, acc)
|
||||
is ActionHandler.Delegate -> error("is not possible")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
override fun initialState(): S = initialState
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> sideEffect(klass: KClass<A>, block: suspend (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
return {
|
||||
ActionHandler.Async(key = klass as KClass<Action>) { action -> block(action as A, getState()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> change(klass: KClass<A>, block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
return {
|
||||
ActionHandler.Sync(key = klass as KClass<Action>, block as (Action, S) -> S)
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> async(klass: KClass<A>, block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
return {
|
||||
ActionHandler.Async(key = klass as KClass<Action>, block as suspend ReducerScope<S>.(Action) -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> multi(klass: KClass<A>, block: Multi<A, S>.(A) -> (ReducerScope<S>) -> ActionHandler<S>): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
val multiScope = object : Multi<A, S> {
|
||||
override fun sideEffect(block: suspend (S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = sideEffect(klass) { _, state -> block(state) }
|
||||
override fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> = change(klass, block)
|
||||
override fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = async(klass, block)
|
||||
override fun nothing() = sideEffect { }
|
||||
}
|
||||
|
||||
return {
|
||||
ActionHandler.Delegate(key = klass as KClass<Action>) { action ->
|
||||
block(multiScope, action as A).invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Multi<A : Action, S> {
|
||||
fun sideEffect(block: suspend (S) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
|
||||
fun nothing(): (ReducerScope<S>) -> ActionHandler<S>
|
||||
fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S>
|
||||
fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
|
||||
}
|
||||
|
||||
interface Action
|
@ -0,0 +1,20 @@
|
||||
package fake
|
||||
|
||||
import org.amshove.kluent.internal.assertEquals
|
||||
|
||||
class FakeEventSource<E> : (E) -> Unit {
|
||||
|
||||
private val captures = mutableListOf<E>()
|
||||
|
||||
override fun invoke(event: E) {
|
||||
captures.add(event)
|
||||
}
|
||||
|
||||
fun assertEvents(expected: List<E>) {
|
||||
assertEquals(expected, captures)
|
||||
}
|
||||
|
||||
fun assertNoEvents() {
|
||||
assertEquals(emptyList(), captures)
|
||||
}
|
||||
}
|
153
domains/state/src/testFixtures/kotlin/test/ReducerTest.kt
Normal file
153
domains/state/src/testFixtures/kotlin/test/ReducerTest.kt
Normal file
@ -0,0 +1,153 @@
|
||||
package test
|
||||
|
||||
import app.dapk.state.Action
|
||||
import app.dapk.state.Reducer
|
||||
import app.dapk.state.ReducerFactory
|
||||
import app.dapk.state.ReducerScope
|
||||
import fake.FakeEventSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.amshove.kluent.internal.assertEquals
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
|
||||
interface ReducerTest<S, E> {
|
||||
operator fun invoke(block: suspend ReducerTestScope<S, E>.() -> Unit)
|
||||
}
|
||||
|
||||
fun <S, E> testReducer(block: ((E) -> Unit) -> ReducerFactory<S>): ReducerTest<S, E> {
|
||||
val fakeEventSource = FakeEventSource<E>()
|
||||
val reducerFactory = block(fakeEventSource)
|
||||
return object : ReducerTest<S, E> {
|
||||
override fun invoke(block: suspend ReducerTestScope<S, E>.() -> Unit) {
|
||||
runReducerTest(reducerFactory, fakeEventSource, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <S, E> runReducerTest(reducerFactory: ReducerFactory<S>, fakeEventSource: FakeEventSource<E>, block: suspend ReducerTestScope<S, E>.() -> Unit) {
|
||||
runTest {
|
||||
val expectTestScope = ExpectTest(coroutineContext)
|
||||
block(ReducerTestScope(reducerFactory, fakeEventSource, expectTestScope))
|
||||
expectTestScope.verifyExpects()
|
||||
}
|
||||
}
|
||||
|
||||
class ReducerTestScope<S, E>(
|
||||
private val reducerFactory: ReducerFactory<S>,
|
||||
private val fakeEventSource: FakeEventSource<E>,
|
||||
private val expectTestScope: ExpectTestScope
|
||||
) : ExpectTestScope by expectTestScope, Reducer<S> {
|
||||
|
||||
private var invalidateCapturedState: Boolean = false
|
||||
private val actionSideEffects = mutableMapOf<Action, () -> S>()
|
||||
private var manualState: S? = null
|
||||
private var capturedResult: S? = null
|
||||
|
||||
private val actionCaptures = mutableListOf<Action>()
|
||||
private val reducerScope = object : ReducerScope<S> {
|
||||
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
|
||||
override fun dispatch(action: Action) {
|
||||
actionCaptures.add(action)
|
||||
|
||||
if (actionSideEffects.containsKey(action)) {
|
||||
setState(actionSideEffects.getValue(action).invoke(), invalidateCapturedState = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState() = manualState ?: reducerFactory.initialState()
|
||||
}
|
||||
private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
|
||||
|
||||
override fun reduce(action: Action) = reducer.reduce(action).also {
|
||||
capturedResult = if (invalidateCapturedState) manualState else it
|
||||
}
|
||||
|
||||
fun actionSideEffect(action: Action, handler: () -> S) {
|
||||
actionSideEffects[action] = handler
|
||||
}
|
||||
|
||||
fun setState(state: S, invalidateCapturedState: Boolean = false) {
|
||||
manualState = state
|
||||
this.invalidateCapturedState = invalidateCapturedState
|
||||
}
|
||||
|
||||
fun setState(block: (S) -> S) {
|
||||
setState(block(reducerScope.getState()))
|
||||
}
|
||||
|
||||
fun assertInitialState(expected: S) {
|
||||
reducerFactory.initialState() shouldBeEqualTo expected
|
||||
}
|
||||
|
||||
fun assertEvents(events: List<E>) {
|
||||
fakeEventSource.assertEvents(events)
|
||||
}
|
||||
|
||||
fun assertOnlyStateChange(expected: S) {
|
||||
assertStateChange(expected)
|
||||
assertNoDispatches()
|
||||
fakeEventSource.assertNoEvents()
|
||||
}
|
||||
|
||||
fun assertOnlyStateChange(block: (S) -> S) {
|
||||
val expected = block(reducerScope.getState())
|
||||
assertStateChange(expected)
|
||||
assertNoDispatches()
|
||||
fakeEventSource.assertNoEvents()
|
||||
}
|
||||
|
||||
fun assertStateChange(expected: S) {
|
||||
capturedResult shouldBeEqualTo expected
|
||||
}
|
||||
|
||||
fun assertDispatches(expected: List<Action>) {
|
||||
assertEquals(expected, actionCaptures)
|
||||
}
|
||||
|
||||
fun assertNoDispatches() {
|
||||
assertEquals(emptyList(), actionCaptures)
|
||||
}
|
||||
|
||||
fun assertNoStateChange() {
|
||||
assertEquals(reducerScope.getState(), capturedResult)
|
||||
}
|
||||
|
||||
fun assertNoEvents() {
|
||||
fakeEventSource.assertNoEvents()
|
||||
}
|
||||
|
||||
fun assertOnlyDispatches(expected: List<Action>) {
|
||||
assertDispatches(expected)
|
||||
fakeEventSource.assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
fun assertOnlyEvents(events: List<E>) {
|
||||
fakeEventSource.assertEvents(events)
|
||||
assertNoDispatches()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
fun assertNoChanges() {
|
||||
assertNoStateChange()
|
||||
assertNoEvents()
|
||||
assertNoDispatches()
|
||||
}
|
||||
}
|
||||
|
||||
fun <S, E> ReducerTestScope<S, E>.assertOnlyDispatches(vararg action: Action) {
|
||||
this.assertOnlyDispatches(action.toList())
|
||||
}
|
||||
|
||||
fun <S, E> ReducerTestScope<S, E>.assertDispatches(vararg action: Action) {
|
||||
this.assertDispatches(action.toList())
|
||||
}
|
||||
|
||||
fun <S, E> ReducerTestScope<S, E>.assertEvents(vararg event: E) {
|
||||
this.assertEvents(event.toList())
|
||||
}
|
||||
|
||||
fun <S, E> ReducerTestScope<S, E>.assertOnlyEvents(vararg event: E) {
|
||||
this.assertOnlyEvents(event.toList())
|
||||
}
|
@ -13,6 +13,7 @@ import app.dapk.st.domain.preference.CachingPreferences
|
||||
import app.dapk.st.domain.preference.PropertyCache
|
||||
import app.dapk.st.domain.profile.ProfilePersistence
|
||||
import app.dapk.st.domain.push.PushTokenRegistrarPreferences
|
||||
import app.dapk.st.domain.room.MutedStorePersistence
|
||||
import app.dapk.st.domain.sync.OverviewPersistence
|
||||
import app.dapk.st.domain.sync.RoomPersistence
|
||||
import app.dapk.st.matrix.common.CredentialsStore
|
||||
@ -33,8 +34,18 @@ class StoreModule(
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
|
||||
private val muteableStore by unsafeLazy { MutedStorePersistence(database, coroutineDispatchers) }
|
||||
|
||||
fun overviewStore(): OverviewStore = OverviewPersistence(database, coroutineDispatchers)
|
||||
fun roomStore(): RoomStore = RoomPersistence(database, OverviewPersistence(database, coroutineDispatchers), coroutineDispatchers)
|
||||
fun roomStore(): RoomStore {
|
||||
return RoomPersistence(
|
||||
database = database,
|
||||
overviewPersistence = OverviewPersistence(database, coroutineDispatchers),
|
||||
coroutineDispatchers = coroutineDispatchers,
|
||||
muteableStore = muteableStore,
|
||||
)
|
||||
}
|
||||
|
||||
fun credentialsStore(): CredentialsStore = CredentialsPreferences(credentialPreferences)
|
||||
fun syncStore(): SyncStore = SyncTokenPreferences(preferences)
|
||||
fun filterStore(): FilterStore = FilterPreferences(preferences)
|
||||
|
@ -4,6 +4,7 @@ import app.dapk.st.core.CachedPreferences
|
||||
import app.dapk.st.core.Preferences
|
||||
|
||||
class CachingPreferences(private val cache: PropertyCache, private val preferences: Preferences) : CachedPreferences {
|
||||
|
||||
override suspend fun store(key: String, value: String) {
|
||||
cache.setValue(key, value)
|
||||
preferences.store(key, value)
|
||||
|
@ -0,0 +1,41 @@
|
||||
package app.dapk.st.domain.room
|
||||
|
||||
import app.dapk.db.DapkDb
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.withIoContext
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.sync.MuteableStore
|
||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal class MutedStorePersistence(
|
||||
private val database: DapkDb,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : MuteableStore {
|
||||
|
||||
private val allMutedFlow = MutableSharedFlow<Set<RoomId>>(replay = 1)
|
||||
|
||||
override suspend fun mute(roomId: RoomId) {
|
||||
coroutineDispatchers.withIoContext {
|
||||
database.mutedRoomQueries.insertMuted(roomId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun unmute(roomId: RoomId) {
|
||||
coroutineDispatchers.withIoContext {
|
||||
database.mutedRoomQueries.removeMuted(roomId.value)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun isMuted(roomId: RoomId) = allMutedFlow.firstOrNull()?.contains(roomId) ?: false
|
||||
|
||||
override fun observeMuted(): Flow<Set<RoomId>> = database.mutedRoomQueries.select()
|
||||
.asFlow()
|
||||
.mapToList()
|
||||
.map { it.map { RoomId(it) }.toSet() }
|
||||
|
||||
}
|
@ -4,12 +4,11 @@ import app.dapk.db.DapkDb
|
||||
import app.dapk.db.model.RoomEventQueries
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.withIoContext
|
||||
import app.dapk.st.domain.room.MutedStorePersistence
|
||||
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.RoomOverview
|
||||
import app.dapk.st.matrix.sync.RoomState
|
||||
import app.dapk.st.matrix.sync.RoomStore
|
||||
import app.dapk.st.matrix.sync.*
|
||||
import com.squareup.sqldelight.Query
|
||||
import com.squareup.sqldelight.runtime.coroutines.asFlow
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToList
|
||||
import com.squareup.sqldelight.runtime.coroutines.mapToOneNotNull
|
||||
@ -25,7 +24,8 @@ internal class RoomPersistence(
|
||||
private val database: DapkDb,
|
||||
private val overviewPersistence: OverviewPersistence,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : RoomStore {
|
||||
private val muteableStore: MutedStorePersistence,
|
||||
) : RoomStore, MuteableStore by muteableStore {
|
||||
|
||||
override suspend fun persist(roomId: RoomId, events: List<RoomEvent>) {
|
||||
coroutineDispatchers.withIoContext {
|
||||
@ -57,10 +57,8 @@ internal class RoomPersistence(
|
||||
}.distinctUntilChanged()
|
||||
|
||||
return database.roomEventQueries.selectRoom(roomId.value)
|
||||
.asFlow()
|
||||
.mapToList()
|
||||
.distinctFlowList()
|
||||
.map { it.map { json.decodeFromString(RoomEvent.serializer(), it) } }
|
||||
.distinctUntilChanged()
|
||||
.combine(overviewFlow) { events, overview ->
|
||||
RoomState(overview, events)
|
||||
}
|
||||
@ -92,9 +90,7 @@ internal class RoomPersistence(
|
||||
|
||||
override fun observeUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
|
||||
return database.roomEventQueries.selectAllUnread()
|
||||
.asFlow()
|
||||
.mapToList()
|
||||
.distinctUntilChanged()
|
||||
.distinctFlowList()
|
||||
.map {
|
||||
it.groupBy { RoomId(it.room_id) }
|
||||
.mapKeys { overviewPersistence.retrieve(it.key)!! }
|
||||
@ -116,6 +112,22 @@ internal class RoomPersistence(
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeNotMutedUnread(): Flow<Map<RoomOverview, List<RoomEvent>>> {
|
||||
return database.roomEventQueries.selectNotMutedUnread()
|
||||
.distinctFlowList()
|
||||
.map {
|
||||
it.groupBy { RoomId(it.room_id) }
|
||||
.mapKeys { overviewPersistence.retrieve(it.key)!! }
|
||||
.mapValues {
|
||||
it.value.map {
|
||||
json.decodeFromString(RoomEvent.serializer(), it.blob)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Any> Query<T>.distinctFlowList() = this.asFlow().mapToList().distinctUntilChanged()
|
||||
|
||||
override suspend fun markRead(roomId: RoomId) {
|
||||
coroutineDispatchers.withIoContext {
|
||||
database.unreadEventQueries.removeRead(room_id = roomId.value)
|
||||
|
@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS dbMutedRoom (
|
||||
room_id TEXT NOT NULL,
|
||||
PRIMARY KEY (room_id)
|
||||
);
|
||||
|
||||
insertMuted:
|
||||
INSERT OR REPLACE INTO dbMutedRoom(room_id)
|
||||
VALUES (?);
|
||||
|
||||
removeMuted:
|
||||
DELETE FROM dbMutedRoom
|
||||
WHERE room_id = ?;
|
||||
|
||||
select:
|
||||
SELECT room_id
|
||||
FROM dbMutedRoom;
|
@ -34,6 +34,16 @@ INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
|
||||
ORDER BY dbRoomEvent.timestamp_utc DESC
|
||||
LIMIT 100;
|
||||
|
||||
selectNotMutedUnread:
|
||||
SELECT dbRoomEvent.blob, dbRoomEvent.room_id
|
||||
FROM dbUnreadEvent
|
||||
INNER JOIN dbRoomEvent ON dbUnreadEvent.event_id = dbRoomEvent.event_id
|
||||
LEFT OUTER JOIN dbMutedRoom
|
||||
ON dbUnreadEvent.room_id = dbMutedRoom.room_id
|
||||
WHERE dbMutedRoom.room_id IS NULL
|
||||
ORDER BY dbRoomEvent.timestamp_utc DESC
|
||||
LIMIT 100;
|
||||
|
||||
remove:
|
||||
DELETE FROM dbRoomEvent
|
||||
WHERE room_id = ?;
|
||||
|
@ -16,7 +16,6 @@ FROM dbRoomMember
|
||||
WHERE room_id = ?
|
||||
LIMIT ?;
|
||||
|
||||
|
||||
insert:
|
||||
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
|
||||
VALUES (?, ?, ?);
|
@ -4,6 +4,7 @@ dependencies {
|
||||
implementation project(":chat-engine")
|
||||
implementation project(":domains:android:compose-core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":domains:state")
|
||||
implementation project(":features:messenger")
|
||||
implementation project(":core")
|
||||
implementation project(":design-library")
|
||||
@ -13,6 +14,7 @@ dependencies {
|
||||
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:state"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:store"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
|
||||
|
@ -2,6 +2,7 @@ package app.dapk.st.directory
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
@ -10,6 +11,13 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Speaker
|
||||
import androidx.compose.material.icons.filled.VolumeMute
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material.icons.outlined.SpeakerNotesOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
@ -35,9 +43,13 @@ import app.dapk.st.design.components.CircleishAvatar
|
||||
import app.dapk.st.design.components.GenericEmpty
|
||||
import app.dapk.st.design.components.GenericError
|
||||
import app.dapk.st.design.components.Toolbar
|
||||
import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl
|
||||
import app.dapk.st.directory.DirectoryScreenState.Content
|
||||
import app.dapk.st.directory.DirectoryScreenState.EmptyLoading
|
||||
import app.dapk.st.directory.state.ComponentLifecycle
|
||||
import app.dapk.st.directory.state.DirectoryEvent
|
||||
import app.dapk.st.directory.state.DirectoryEvent.OpenDownloadUrl
|
||||
import app.dapk.st.directory.state.DirectoryScreenState
|
||||
import app.dapk.st.directory.state.DirectoryScreenState.Content
|
||||
import app.dapk.st.directory.state.DirectoryScreenState.EmptyLoading
|
||||
import app.dapk.st.directory.state.DirectoryState
|
||||
import app.dapk.st.engine.DirectoryItem
|
||||
import app.dapk.st.engine.RoomOverview
|
||||
import app.dapk.st.engine.Typing
|
||||
@ -53,8 +65,8 @@ import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
|
||||
val state = directoryViewModel.state
|
||||
fun DirectoryScreen(directoryViewModel: DirectoryState) {
|
||||
val state = directoryViewModel.current
|
||||
|
||||
val listState: LazyListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = 0,
|
||||
@ -68,8 +80,8 @@ fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
|
||||
directoryViewModel.ObserveEvents(listState, toolbarOffsetHeightPx)
|
||||
|
||||
LifecycleEffect(
|
||||
onStart = { directoryViewModel.start() },
|
||||
onStop = { directoryViewModel.stop() }
|
||||
onStart = { directoryViewModel.dispatch(ComponentLifecycle.OnVisible) },
|
||||
onStop = { directoryViewModel.dispatch(ComponentLifecycle.OnGone) }
|
||||
)
|
||||
|
||||
val nestedScrollConnection = remember {
|
||||
@ -101,7 +113,7 @@ fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectoryViewModel.ObserveEvents(listState: LazyListState, toolbarPosition: MutableState<Float>) {
|
||||
private fun DirectoryState.ObserveEvents(listState: LazyListState, toolbarPosition: MutableState<Float>) {
|
||||
val context = LocalContext.current
|
||||
StartObserving {
|
||||
this@ObserveEvents.events.launch {
|
||||
@ -194,36 +206,24 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock:
|
||||
)
|
||||
}
|
||||
|
||||
if (hasUnread) {
|
||||
if (hasUnread || room.isMuted) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
body(overview, secondaryText, room.typing)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Box(Modifier.align(Alignment.CenterVertically)) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.background(color = MaterialTheme.colorScheme.primary, shape = CircleShape)
|
||||
.size(22.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val unreadTextSize = when (room.unreadCount.value > 99) {
|
||||
true -> 9.sp
|
||||
false -> 10.sp
|
||||
}
|
||||
val unreadLabelContent = when {
|
||||
room.unreadCount.value > 99 -> "99+"
|
||||
else -> room.unreadCount.value.toString()
|
||||
}
|
||||
Text(
|
||||
fontSize = unreadTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
text = unreadLabelContent,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
if (hasUnread) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Box(Modifier.align(Alignment.CenterVertically)) {
|
||||
UnreadCircle(room)
|
||||
}
|
||||
}
|
||||
if (room.isMuted) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.VolumeOff,
|
||||
contentDescription = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body(overview, secondaryText, room.typing)
|
||||
@ -233,6 +233,32 @@ private fun DirectoryItem(room: DirectoryItem, onClick: (RoomId) -> Unit, clock:
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.UnreadCircle(room: DirectoryItem) {
|
||||
Box(
|
||||
Modifier.Companion
|
||||
.align(Alignment.Center)
|
||||
.background(color = MaterialTheme.colorScheme.primary, shape = CircleShape)
|
||||
.size(22.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val unreadTextSize = when (room.unreadCount.value > 99) {
|
||||
true -> 9.sp
|
||||
false -> 10.sp
|
||||
}
|
||||
val unreadLabelContent = when {
|
||||
room.unreadCount.value > 99 -> "99+"
|
||||
else -> room.unreadCount.value.toString()
|
||||
}
|
||||
Text(
|
||||
fontSize = unreadTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
text = unreadLabelContent,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun body(overview: RoomOverview, secondaryText: Color, typing: Typing?) {
|
||||
val bodySize = 14.sp
|
||||
|
@ -2,6 +2,10 @@ package app.dapk.st.directory
|
||||
|
||||
import android.content.Context
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.core.createStateViewModel
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.directory.state.DirectoryState
|
||||
import app.dapk.st.directory.state.directoryReducer
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
|
||||
class DirectoryModule(
|
||||
@ -9,10 +13,7 @@ class DirectoryModule(
|
||||
private val chatEngine: ChatEngine,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun directoryViewModel(): DirectoryViewModel {
|
||||
return DirectoryViewModel(
|
||||
ShortcutHandler(context),
|
||||
chatEngine,
|
||||
)
|
||||
fun directoryState(): DirectoryState {
|
||||
return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,45 +0,0 @@
|
||||
package app.dapk.st.directory
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.directory.DirectoryScreenState.*
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import app.dapk.st.viewmodel.MutableStateFactory
|
||||
import app.dapk.st.viewmodel.defaultStateFactory
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DirectoryViewModel(
|
||||
private val shortcutHandler: ShortcutHandler,
|
||||
private val chatEngine: ChatEngine,
|
||||
factory: MutableStateFactory<DirectoryScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<DirectoryScreenState, DirectoryEvent>(
|
||||
initialState = EmptyLoading,
|
||||
factory,
|
||||
) {
|
||||
|
||||
private var syncJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
syncJob = viewModelScope.launch {
|
||||
chatEngine.directory().onEach {
|
||||
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
|
||||
state = when (it.isEmpty()) {
|
||||
true -> Empty
|
||||
false -> Content(it)
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
syncJob?.cancel()
|
||||
}
|
||||
|
||||
fun scrollToTopOfMessages() {
|
||||
_events.tryEmit(DirectoryEvent.ScrollToTop)
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import app.dapk.st.engine.RoomOverview
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.messenger.MessengerActivity
|
||||
|
||||
class ShortcutHandler(private val context: Context) {
|
||||
internal class ShortcutHandler(private val context: Context) {
|
||||
|
||||
private val cachedRoomIds = mutableListOf<RoomId>()
|
||||
|
||||
|
@ -0,0 +1,18 @@
|
||||
package app.dapk.st.directory.state
|
||||
|
||||
import app.dapk.st.engine.DirectoryState
|
||||
import app.dapk.state.Action
|
||||
|
||||
sealed interface ComponentLifecycle : Action {
|
||||
object OnVisible : ComponentLifecycle
|
||||
object OnGone : ComponentLifecycle
|
||||
}
|
||||
|
||||
sealed interface DirectorySideEffect : Action {
|
||||
object ScrollToTop : DirectorySideEffect
|
||||
}
|
||||
|
||||
sealed interface DirectoryStateChange : Action {
|
||||
object Empty : DirectoryStateChange
|
||||
data class Content(val content: DirectoryState) : DirectoryStateChange
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
package app.dapk.st.directory.state
|
||||
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.directory.ShortcutHandler
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.state.*
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
private const val KEY_SYNCING_JOB = "sync"
|
||||
|
||||
internal fun directoryReducer(
|
||||
chatEngine: ChatEngine,
|
||||
shortcutHandler: ShortcutHandler,
|
||||
jobBag: JobBag,
|
||||
eventEmitter: suspend (DirectoryEvent) -> Unit,
|
||||
): ReducerFactory<DirectoryScreenState> {
|
||||
return createReducer(
|
||||
initialState = DirectoryScreenState.EmptyLoading,
|
||||
|
||||
multi(ComponentLifecycle::class) { action ->
|
||||
when (action) {
|
||||
ComponentLifecycle.OnVisible -> async { _ ->
|
||||
jobBag.replace(KEY_SYNCING_JOB, chatEngine.directory().onEach {
|
||||
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
|
||||
when (it.isEmpty()) {
|
||||
true -> dispatch(DirectoryStateChange.Empty)
|
||||
false -> dispatch(DirectoryStateChange.Content(it))
|
||||
}
|
||||
}.launchIn(coroutineScope))
|
||||
}
|
||||
|
||||
ComponentLifecycle.OnGone -> sideEffect { jobBag.cancel(KEY_SYNCING_JOB) }
|
||||
}
|
||||
},
|
||||
|
||||
change(DirectoryStateChange::class) { action, _ ->
|
||||
when (action) {
|
||||
is DirectoryStateChange.Content -> DirectoryScreenState.Content(action.content)
|
||||
DirectoryStateChange.Empty -> DirectoryScreenState.Empty
|
||||
}
|
||||
},
|
||||
|
||||
sideEffect(DirectorySideEffect.ScrollToTop::class) { _, _ ->
|
||||
eventEmitter(DirectoryEvent.ScrollToTop)
|
||||
}
|
||||
)
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package app.dapk.st.directory
|
||||
package app.dapk.st.directory.state
|
||||
|
||||
import app.dapk.st.core.State
|
||||
import app.dapk.st.engine.DirectoryState
|
||||
|
||||
sealed interface DirectoryScreenState {
|
||||
typealias DirectoryState = State<DirectoryScreenState, DirectoryEvent>
|
||||
|
||||
sealed interface DirectoryScreenState {
|
||||
object EmptyLoading : DirectoryScreenState
|
||||
object Empty : DirectoryScreenState
|
||||
data class Content(
|
||||
@ -15,4 +17,3 @@ sealed interface DirectoryEvent {
|
||||
data class OpenDownloadUrl(val url: String) : DirectoryEvent
|
||||
object ScrollToTop : DirectoryEvent
|
||||
}
|
||||
|
@ -0,0 +1,92 @@
|
||||
package app.dapk.st.directory
|
||||
|
||||
import app.dapk.st.directory.state.*
|
||||
import app.dapk.st.engine.UnreadCount
|
||||
import fake.FakeChatEngine
|
||||
import fake.FakeJobBag
|
||||
import fixture.aRoomOverview
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
import test.expect
|
||||
import test.testReducer
|
||||
|
||||
private val AN_OVERVIEW = aRoomOverview()
|
||||
private val AN_OVERVIEW_STATE = app.dapk.st.engine.DirectoryItem(AN_OVERVIEW, UnreadCount(1), null, isMuted = false)
|
||||
|
||||
class DirectoryReducerTest {
|
||||
|
||||
private val fakeShortcutHandler = FakeShortcutHandler()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
|
||||
private val runReducerTest = testReducer { fakeEventSource ->
|
||||
directoryReducer(
|
||||
fakeChatEngine,
|
||||
fakeShortcutHandler.instance,
|
||||
fakeJobBag.instance,
|
||||
fakeEventSource,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is empty loading`() = runReducerTest {
|
||||
assertInitialState(DirectoryScreenState.EmptyLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest {
|
||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) }
|
||||
fakeJobBag.instance.expect { it.replace("sync", any()) }
|
||||
fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE)))
|
||||
|
||||
reduce(ComponentLifecycle.OnVisible)
|
||||
|
||||
assertOnlyDispatches(listOf(DirectoryStateChange.Content(listOf(AN_OVERVIEW_STATE))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest {
|
||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(emptyList()) }
|
||||
fakeJobBag.instance.expect { it.replace("sync", any()) }
|
||||
fakeChatEngine.givenDirectory().returns(flowOf(emptyList()))
|
||||
|
||||
reduce(ComponentLifecycle.OnVisible)
|
||||
|
||||
assertOnlyDispatches(listOf(DirectoryStateChange.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Gone, then cancels sync job`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.cancel("sync") }
|
||||
|
||||
reduce(ComponentLifecycle.OnGone)
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when ScrollToTop, then emits Scroll event`() = runReducerTest {
|
||||
reduce(DirectorySideEffect.ScrollToTop)
|
||||
|
||||
assertOnlyEvents(listOf(DirectoryEvent.ScrollToTop))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Content StateChange, then returns Content state`() = runReducerTest {
|
||||
reduce(DirectoryStateChange.Content(listOf(AN_OVERVIEW_STATE)))
|
||||
|
||||
assertOnlyStateChange(DirectoryScreenState.Content(listOf(AN_OVERVIEW_STATE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Empty StateChange, then returns Empty state`() = runReducerTest {
|
||||
reduce(DirectoryStateChange.Empty)
|
||||
|
||||
assertOnlyStateChange(DirectoryScreenState.Empty)
|
||||
}
|
||||
}
|
||||
|
||||
internal class FakeShortcutHandler {
|
||||
val instance = mockk<ShortcutHandler>()
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
package app.dapk.st.directory
|
||||
|
||||
import ViewModelTest
|
||||
import app.dapk.st.engine.DirectoryItem
|
||||
import app.dapk.st.engine.UnreadCount
|
||||
import fake.FakeChatEngine
|
||||
import fixture.aRoomOverview
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
|
||||
private val AN_OVERVIEW = aRoomOverview()
|
||||
private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null)
|
||||
|
||||
class DirectoryViewModelTest {
|
||||
|
||||
private val runViewModelTest = ViewModelTest()
|
||||
private val fakeShortcutHandler = FakeShortcutHandler()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
|
||||
private val viewModel = DirectoryViewModel(
|
||||
fakeShortcutHandler.instance,
|
||||
fakeChatEngine,
|
||||
runViewModelTest.testMutableStateFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when creating view model, then initial state is empty loading`() = runViewModelTest {
|
||||
viewModel.test()
|
||||
|
||||
assertInitialState(DirectoryScreenState.EmptyLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when starting, then updates shortcuts and emits room state`() = runViewModelTest {
|
||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) }
|
||||
fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE)))
|
||||
|
||||
viewModel.test().start()
|
||||
|
||||
assertStates(DirectoryScreenState.Content(listOf(AN_OVERVIEW_STATE)))
|
||||
verifyExpects()
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShortcutHandler {
|
||||
val instance = mockk<ShortcutHandler>()
|
||||
}
|
@ -9,6 +9,7 @@ dependencies {
|
||||
implementation project(":domains:android:compose-core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(':domains:store')
|
||||
implementation project(':domains:state')
|
||||
implementation project(":core")
|
||||
implementation project(":design-library")
|
||||
implementation Dependencies.mavenCentral.coil
|
||||
|
@ -1,7 +1,7 @@
|
||||
package app.dapk.st.home
|
||||
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.directory.DirectoryViewModel
|
||||
import app.dapk.st.directory.state.DirectoryState
|
||||
import app.dapk.st.domain.StoreModule
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.login.LoginViewModel
|
||||
@ -13,7 +13,7 @@ class HomeModule(
|
||||
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
|
||||
internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
|
||||
return HomeViewModel(
|
||||
chatEngine,
|
||||
storeModule.credentialsStore(),
|
||||
|
@ -18,7 +18,7 @@ import app.dapk.st.profile.ProfileScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(homeViewModel: HomeViewModel) {
|
||||
internal fun HomeScreen(homeViewModel: HomeViewModel) {
|
||||
LifecycleEffect(
|
||||
onStart = { homeViewModel.start() },
|
||||
onStop = { homeViewModel.stop() }
|
||||
|
@ -1,7 +1,9 @@
|
||||
package app.dapk.st.home
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.directory.DirectoryViewModel
|
||||
import app.dapk.st.directory.state.ComponentLifecycle
|
||||
import app.dapk.st.directory.state.DirectorySideEffect
|
||||
import app.dapk.st.directory.state.DirectoryState
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.home.HomeScreenState.*
|
||||
@ -17,10 +19,10 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HomeViewModel(
|
||||
internal class HomeViewModel(
|
||||
private val chatEngine: ChatEngine,
|
||||
private val credentialsProvider: CredentialsStore,
|
||||
private val directoryViewModel: DirectoryViewModel,
|
||||
private val directoryState: DirectoryState,
|
||||
private val loginViewModel: LoginViewModel,
|
||||
private val profileViewModel: ProfileViewModel,
|
||||
private val cacheCleaner: StoreCleaner,
|
||||
@ -31,7 +33,7 @@ class HomeViewModel(
|
||||
|
||||
private var listenForInvitesJob: Job? = null
|
||||
|
||||
fun directory() = directoryViewModel
|
||||
fun directory() = directoryState
|
||||
fun login() = loginViewModel
|
||||
fun profile() = profileViewModel
|
||||
|
||||
@ -56,7 +58,11 @@ class HomeViewModel(
|
||||
private suspend fun initialHomeContent(): SignedIn {
|
||||
val me = chatEngine.me(forceRefresh = false)
|
||||
val initialInvites = chatEngine.invites().first().size
|
||||
return SignedIn(Page.Directory, me, invites = initialInvites)
|
||||
return when (val current = state) {
|
||||
Loading -> SignedIn(Page.Directory, me, invites = initialInvites)
|
||||
is SignedIn -> current.copy(me = me, invites = initialInvites)
|
||||
SignedOut -> SignedIn(Page.Directory, me, invites = initialInvites)
|
||||
}
|
||||
}
|
||||
|
||||
fun loggedIn() {
|
||||
@ -92,7 +98,7 @@ class HomeViewModel(
|
||||
}
|
||||
|
||||
fun scrollToTopOfMessages() {
|
||||
directoryViewModel.scrollToTopOfMessages()
|
||||
directoryState.dispatch(DirectorySideEffect.ScrollToTop)
|
||||
}
|
||||
|
||||
fun changePage(page: Page) {
|
||||
@ -117,7 +123,10 @@ class HomeViewModel(
|
||||
// do nothing
|
||||
}
|
||||
|
||||
Page.Profile -> profileViewModel.reset()
|
||||
Page.Profile -> {
|
||||
directoryState.dispatch(ComponentLifecycle.OnGone)
|
||||
profileViewModel.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.dapk.st.core.DapkActivity
|
||||
import app.dapk.st.core.module
|
||||
import app.dapk.st.core.state
|
||||
import app.dapk.st.core.viewModel
|
||||
import app.dapk.st.directory.DirectoryModule
|
||||
import app.dapk.st.login.LoginModule
|
||||
@ -22,7 +23,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
|
||||
class MainActivity : DapkActivity() {
|
||||
|
||||
private val directoryViewModel by viewModel { module<DirectoryModule>().directoryViewModel() }
|
||||
private val directoryViewModel by state { module<DirectoryModule>().directoryState() }
|
||||
private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() }
|
||||
private val profileViewModel by viewModel { module<ProfileModule>().profileViewModel() }
|
||||
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) }
|
||||
|
@ -6,6 +6,7 @@ dependencies {
|
||||
implementation project(":domains:android:compose-core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":domains:store")
|
||||
implementation project(":domains:state")
|
||||
implementation project(":core")
|
||||
implementation project(":features:navigator")
|
||||
implementation project(":design-library")
|
||||
@ -16,6 +17,7 @@ dependencies {
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:store"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:state"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
|
||||
androidImportFixturesWorkaround(project, project(":chat-engine"))
|
||||
|
@ -14,6 +14,10 @@ import app.dapk.st.core.*
|
||||
import app.dapk.st.core.extensions.unsafeLazy
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.messenger.gallery.GetImageFromGallery
|
||||
import app.dapk.st.messenger.state.ComposerStateChange
|
||||
import app.dapk.st.messenger.state.MessengerEvent
|
||||
import app.dapk.st.messenger.state.MessengerScreenState
|
||||
import app.dapk.st.messenger.state.MessengerState
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@ -23,7 +27,7 @@ val LocalImageRequestFactory = staticCompositionLocalOf<ImageRequest.Builder> {
|
||||
class MessengerActivity : DapkActivity() {
|
||||
|
||||
private val module by unsafeLazy { module<MessengerModule>() }
|
||||
private val viewModel by viewModel { module.messengerViewModel() }
|
||||
private val state by state { module.messengerState(readPayload()) }
|
||||
|
||||
companion object {
|
||||
|
||||
@ -54,8 +58,8 @@ class MessengerActivity : DapkActivity() {
|
||||
|
||||
val galleryLauncher = registerForActivityResult(GetImageFromGallery()) {
|
||||
it?.let { uri ->
|
||||
viewModel.post(
|
||||
MessengerAction.ComposerImageUpdate(
|
||||
state.dispatch(
|
||||
ComposerStateChange.SelectAttachmentToSend(
|
||||
MessageAttachment(
|
||||
AndroidUri(it.toString()),
|
||||
MimeType.Image,
|
||||
@ -68,7 +72,7 @@ class MessengerActivity : DapkActivity() {
|
||||
setContent {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(LocalImageRequestFactory provides factory) {
|
||||
MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher)
|
||||
MessengerScreen(state, navigator, galleryLauncher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,10 +3,14 @@ package app.dapk.st.messenger
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import app.dapk.st.core.DeviceMeta
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.core.createStateViewModel
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.messenger.state.MessengerState
|
||||
import app.dapk.st.messenger.state.messengerReducer
|
||||
|
||||
class MessengerModule(
|
||||
private val chatEngine: ChatEngine,
|
||||
@ -15,13 +19,19 @@ class MessengerModule(
|
||||
private val deviceMeta: DeviceMeta,
|
||||
) : ProvidableModule {
|
||||
|
||||
internal fun messengerViewModel(): MessengerViewModel {
|
||||
return MessengerViewModel(
|
||||
chatEngine,
|
||||
messageOptionsStore,
|
||||
CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager),
|
||||
deviceMeta,
|
||||
)
|
||||
internal fun messengerState(launchPayload: MessagerActivityPayload): MessengerState {
|
||||
return createStateViewModel {
|
||||
messengerReducer(
|
||||
JobBag(),
|
||||
chatEngine,
|
||||
CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager),
|
||||
deviceMeta,
|
||||
messageOptionsStore,
|
||||
RoomId(launchPayload.roomId),
|
||||
launchPayload.attachments,
|
||||
it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, roomId, chatEngine.mediaDecrypter())
|
||||
|
@ -45,14 +45,13 @@ import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.core.extensions.takeIfContent
|
||||
import app.dapk.st.design.components.*
|
||||
import app.dapk.st.engine.MessageMeta
|
||||
import app.dapk.st.engine.MessengerState
|
||||
import app.dapk.st.engine.MessengerPageState
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.engine.RoomState
|
||||
import app.dapk.st.matrix.common.RichText
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.st.messenger.state.*
|
||||
import app.dapk.st.navigator.Navigator
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
@ -62,18 +61,16 @@ import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun MessengerScreen(
|
||||
roomId: RoomId,
|
||||
attachments: List<MessageAttachment>?,
|
||||
viewModel: MessengerViewModel,
|
||||
viewModel: MessengerState,
|
||||
navigator: Navigator,
|
||||
galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>
|
||||
) {
|
||||
val state = viewModel.state
|
||||
val state = viewModel.current
|
||||
|
||||
viewModel.ObserveEvents(galleryLauncher)
|
||||
LifecycleEffect(
|
||||
onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) },
|
||||
onStop = { viewModel.post(MessengerAction.OnMessengerGone) }
|
||||
onStart = { viewModel.dispatch(ComponentLifecycle.Visible) },
|
||||
onStop = { viewModel.dispatch(ComponentLifecycle.Gone) }
|
||||
)
|
||||
|
||||
val roomTitle = when (val roomState = state.roomState) {
|
||||
@ -82,27 +79,38 @@ internal fun MessengerScreen(
|
||||
}
|
||||
|
||||
val messageActions = MessageActions(
|
||||
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
|
||||
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
|
||||
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) },
|
||||
onImageClick = { viewModel.selectImage(it) }
|
||||
onReply = { viewModel.dispatch(ComposerStateChange.ReplyMode.Enter(it)) },
|
||||
onDismiss = { viewModel.dispatch(ComposerStateChange.ReplyMode.Exit) },
|
||||
onLongClick = { viewModel.dispatch(ScreenAction.CopyToClipboard(it)) },
|
||||
onImageClick = { viewModel.dispatch(ComposerStateChange.ImagePreview.Show(it)) }
|
||||
)
|
||||
|
||||
Column {
|
||||
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
|
||||
// OverflowMenu {
|
||||
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
|
||||
// }
|
||||
state.roomState.takeIfContent()?.let {
|
||||
OverflowMenu {
|
||||
when (it.isMuted) {
|
||||
true -> DropdownMenuItem(text = { Text("Unmute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {
|
||||
viewModel.dispatch(ScreenAction.Notifications.Unmute)
|
||||
})
|
||||
|
||||
false -> DropdownMenuItem(text = { Text("Mute notifications", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {
|
||||
viewModel.dispatch(ScreenAction.Notifications.Mute)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
when (state.composerState) {
|
||||
is ComposerState.Text -> {
|
||||
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
|
||||
Room(state.roomState, messageActions, onRetry = { viewModel.dispatch(ComponentLifecycle.Visible) })
|
||||
TextComposer(
|
||||
state.composerState,
|
||||
onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) },
|
||||
onSend = { viewModel.post(MessengerAction.ComposerSendText) },
|
||||
onAttach = { viewModel.startAttachment() },
|
||||
onTextChange = { viewModel.dispatch(ComposerStateChange.TextUpdate(it)) },
|
||||
onSend = { viewModel.dispatch(ScreenAction.SendMessage) },
|
||||
onAttach = { viewModel.dispatch(ScreenAction.OpenGalleryPicker) },
|
||||
messageActions = messageActions,
|
||||
)
|
||||
}
|
||||
@ -110,8 +118,8 @@ internal fun MessengerScreen(
|
||||
is ComposerState.Attachments -> {
|
||||
AttachmentComposer(
|
||||
state.composerState,
|
||||
onSend = { viewModel.post(MessengerAction.ComposerSendText) },
|
||||
onCancel = { viewModel.post(MessengerAction.ComposerClear) }
|
||||
onSend = { viewModel.dispatch(ScreenAction.SendMessage) },
|
||||
onCancel = { viewModel.dispatch(ComposerStateChange.Clear) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -124,10 +132,10 @@ internal fun MessengerScreen(
|
||||
|
||||
else -> {
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
BackHandler(onBack = { viewModel.unselectImage() })
|
||||
BackHandler(onBack = { viewModel.dispatch(ComposerStateChange.ImagePreview.Hide) })
|
||||
ZoomableImage(state.viewerState)
|
||||
Toolbar(
|
||||
onNavigate = { viewModel.unselectImage() },
|
||||
onNavigate = { viewModel.dispatch(ComposerStateChange.ImagePreview.Hide) },
|
||||
title = state.viewerState.event.event.authorName,
|
||||
color = Color.Black.copy(alpha = 0.4f),
|
||||
)
|
||||
@ -199,13 +207,13 @@ private fun ZoomableImage(viewerState: ViewerState) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
|
||||
private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
|
||||
val context = LocalContext.current
|
||||
StartObserving {
|
||||
this@ObserveEvents.events.launch {
|
||||
when (it) {
|
||||
MessengerEvent.SelectImageAttachment -> {
|
||||
state.roomState.takeIfContent()?.let {
|
||||
current.roomState.takeIfContent()?.let {
|
||||
galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: ""))
|
||||
}
|
||||
}
|
||||
@ -219,7 +227,7 @@ private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLaun
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.Room(roomStateLce: Lce<MessengerState>, messageActions: MessageActions, onRetry: () -> Unit) {
|
||||
private fun ColumnScope.Room(roomStateLce: Lce<MessengerPageState>, messageActions: MessageActions, onRetry: () -> Unit) {
|
||||
when (val state = roomStateLce) {
|
||||
is Lce.Loading -> CenteredLoading()
|
||||
is Lce.Content -> {
|
||||
@ -308,6 +316,7 @@ private fun RoomEvent.toModel(): BubbleModel {
|
||||
return when (this) {
|
||||
is RoomEvent.Message -> BubbleModel.Text(this.content.toApp(), event)
|
||||
is RoomEvent.Encrypted -> BubbleModel.Encrypted(event)
|
||||
is RoomEvent.Redacted -> BubbleModel.Redacted(event)
|
||||
is RoomEvent.Image -> {
|
||||
val imageRequest = LocalImageRequestFactory.current
|
||||
.memoryCacheKey(this.imageMeta.url)
|
||||
@ -435,7 +444,7 @@ private fun TextComposer(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = it.content.toApp().toAnnotatedText(),
|
||||
text = it.content.toApp().toAnnotatedText(isSelf = false),
|
||||
color = SmallTalkTheme.extendedColors.onOthersBubble,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 2,
|
||||
|
@ -1,197 +0,0 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DeviceMeta
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.asString
|
||||
import app.dapk.st.core.extensions.takeIfContent
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.engine.SendMessage
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import app.dapk.st.viewmodel.MutableStateFactory
|
||||
import app.dapk.st.viewmodel.defaultStateFactory
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class MessengerViewModel(
|
||||
private val chatEngine: ChatEngine,
|
||||
private val messageOptionsStore: MessageOptionsStore,
|
||||
private val copyToClipboard: CopyToClipboard,
|
||||
private val deviceMeta: DeviceMeta,
|
||||
factory: MutableStateFactory<MessengerScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<MessengerScreenState, MessengerEvent>(
|
||||
initialState = MessengerScreenState(
|
||||
roomId = null,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
),
|
||||
factory = factory,
|
||||
) {
|
||||
|
||||
private var syncJob: Job? = null
|
||||
|
||||
fun post(action: MessengerAction) {
|
||||
when (action) {
|
||||
is MessengerAction.OnMessengerVisible -> start(action)
|
||||
MessengerAction.OnMessengerGone -> syncJob?.cancel()
|
||||
is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue, composerState.reply)) }
|
||||
MessengerAction.ComposerSendText -> sendMessage()
|
||||
MessengerAction.ComposerClear -> resetComposer()
|
||||
is MessengerAction.ComposerImageUpdate -> updateState {
|
||||
copy(
|
||||
composerState = ComposerState.Attachments(
|
||||
listOf(action.newValue),
|
||||
composerState.reply
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is MessengerAction.ComposerEnterReplyMode -> updateState {
|
||||
copy(
|
||||
composerState = when (composerState) {
|
||||
is ComposerState.Attachments -> composerState.copy(reply = action.replyingTo)
|
||||
is ComposerState.Text -> composerState.copy(reply = action.replyingTo)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
MessengerAction.ComposerExitReplyMode -> updateState {
|
||||
copy(
|
||||
composerState = when (composerState) {
|
||||
is ComposerState.Attachments -> composerState.copy(reply = null)
|
||||
is ComposerState.Text -> composerState.copy(reply = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is MessengerAction.CopyToClipboard -> {
|
||||
viewModelScope.launch {
|
||||
when (val result = action.model.findCopyableContent()) {
|
||||
is CopyableResult.Content -> {
|
||||
copyToClipboard.copy(result.value)
|
||||
if (deviceMeta.apiVersion <= Build.VERSION_CODES.S_V2) {
|
||||
_events.emit(MessengerEvent.Toast("Copied to clipboard"))
|
||||
}
|
||||
}
|
||||
|
||||
CopyableResult.NothingToCopy -> _events.emit(MessengerEvent.Toast("Nothing to copy"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(action: MessengerAction.OnMessengerVisible) {
|
||||
updateState { copy(roomId = action.roomId, composerState = action.attachments?.let { ComposerState.Attachments(it, null) } ?: composerState) }
|
||||
viewModelScope.launch {
|
||||
syncJob = chatEngine.messages(action.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled())
|
||||
.onEach { updateState { copy(roomState = Lce.Content(it)) } }
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun sendMessage() {
|
||||
when (val composerState = state.composerState) {
|
||||
is ComposerState.Text -> {
|
||||
val copy = composerState.copy()
|
||||
updateState { copy(composerState = composerState.copy(value = "", reply = null)) }
|
||||
|
||||
state.roomState.takeIfContent()?.let { content ->
|
||||
val roomState = content.roomState
|
||||
viewModelScope.launch {
|
||||
chatEngine.send(
|
||||
message = SendMessage.TextMessage(
|
||||
content = copy.value,
|
||||
reply = copy.reply?.let {
|
||||
SendMessage.TextMessage.Reply(
|
||||
author = it.author,
|
||||
originalMessage = when (it) {
|
||||
is RoomEvent.Image -> TODO()
|
||||
is RoomEvent.Reply -> TODO()
|
||||
is RoomEvent.Message -> it.content.asString()
|
||||
is RoomEvent.Encrypted -> error("Should never happen")
|
||||
},
|
||||
eventId = it.eventId,
|
||||
timestampUtc = it.utcTimestamp,
|
||||
)
|
||||
}
|
||||
),
|
||||
room = roomState.roomOverview,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ComposerState.Attachments -> {
|
||||
val copy = composerState.copy()
|
||||
resetComposer()
|
||||
|
||||
state.roomState.takeIfContent()?.let { content ->
|
||||
val roomState = content.roomState
|
||||
viewModelScope.launch {
|
||||
chatEngine.send(SendMessage.ImageMessage(uri = copy.values.first().uri.value), roomState.roomOverview)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetComposer() {
|
||||
updateState { copy(composerState = ComposerState.Text("", reply = null)) }
|
||||
}
|
||||
|
||||
fun startAttachment() {
|
||||
viewModelScope.launch {
|
||||
_events.emit(MessengerEvent.SelectImageAttachment)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectImage(image: BubbleModel.Image) {
|
||||
updateState {
|
||||
copy(viewerState = ViewerState(image))
|
||||
}
|
||||
}
|
||||
|
||||
fun unselectImage() {
|
||||
updateState {
|
||||
copy(viewerState = null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
|
||||
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
|
||||
is BubbleModel.Image -> CopyableResult.NothingToCopy
|
||||
is BubbleModel.Reply -> this.reply.findCopyableContent()
|
||||
is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content.asString()))
|
||||
}
|
||||
|
||||
private sealed interface CopyableResult {
|
||||
object NothingToCopy : CopyableResult
|
||||
data class Content(val value: CopyToClipboard.Copyable) : CopyableResult
|
||||
}
|
||||
|
||||
sealed interface MessengerAction {
|
||||
data class ComposerTextUpdate(val newValue: String) : MessengerAction
|
||||
data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction
|
||||
object ComposerExitReplyMode : MessengerAction
|
||||
data class CopyToClipboard(val model: BubbleModel) : MessengerAction
|
||||
data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction
|
||||
object ComposerSendText : MessengerAction
|
||||
object ComposerClear : MessengerAction
|
||||
data class OnMessengerVisible(val roomId: RoomId, val attachments: List<MessageAttachment>?) : MessengerAction
|
||||
object OnMessengerGone : MessengerAction
|
||||
}
|
@ -1,37 +1,39 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore.Images
|
||||
import app.dapk.st.core.ContentResolverQuery
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.reduce
|
||||
import app.dapk.st.core.withIoContext
|
||||
|
||||
class FetchMediaFoldersUseCase(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val uriAvoidance: MediaUriAvoidance,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
|
||||
suspend fun fetchFolders(): List<Folder> {
|
||||
return dispatchers.withIoContext {
|
||||
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED)
|
||||
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?"
|
||||
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
|
||||
val query = ContentResolverQuery(
|
||||
uriAvoidance.externalContentUri,
|
||||
listOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED),
|
||||
"${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?",
|
||||
listOf("%image/svg%"),
|
||||
"${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
|
||||
)
|
||||
|
||||
val folders = mutableMapOf<String, Folder>()
|
||||
val contentUri = Images.Media.EXTERNAL_CONTENT_URI
|
||||
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
|
||||
val thumbnail = ContentUris.withAppendedId(contentUri, rowId)
|
||||
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1]))
|
||||
val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: ""
|
||||
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
|
||||
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
|
||||
folder.incrementItemCount()
|
||||
}
|
||||
}
|
||||
folders.values.toList()
|
||||
contentResolver.reduce(query, mutableMapOf<String, Folder>()) { acc, cursor ->
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media._ID))
|
||||
val thumbnail = uriAvoidance.uriAppender(query.uri, rowId)
|
||||
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_ID))
|
||||
val title = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME)) ?: ""
|
||||
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED))
|
||||
val folder = acc.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
|
||||
folder.incrementItemCount()
|
||||
acc
|
||||
}.values.toList()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,15 +1,20 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import app.dapk.st.core.ContentResolverQuery
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.reduce
|
||||
import app.dapk.st.core.withIoContext
|
||||
|
||||
class FetchMediaUseCase(private val contentResolver: ContentResolver, private val dispatchers: CoroutineDispatchers) {
|
||||
class FetchMediaUseCase(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val uriAvoidance: MediaUriAvoidance,
|
||||
private val dispatchers: CoroutineDispatchers
|
||||
) {
|
||||
|
||||
private val projection = arrayOf(
|
||||
private val projection = listOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MediaStore.Images.Media.DATE_MODIFIED,
|
||||
@ -22,26 +27,26 @@ class FetchMediaUseCase(private val contentResolver: ContentResolver, private va
|
||||
private val selection = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?"
|
||||
|
||||
suspend fun getMediaInBucket(bucketId: String): List<Media> {
|
||||
|
||||
return dispatchers.withIoContext {
|
||||
val media = mutableListOf<Media>()
|
||||
val selectionArgs = arrayOf(bucketId, "%image/svg%")
|
||||
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
|
||||
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
|
||||
val uri = ContentUris.withAppendedId(contentUri, rowId)
|
||||
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
|
||||
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
|
||||
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
|
||||
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
|
||||
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
|
||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
|
||||
media.add(Media(rowId, uri, mimetype, width, height, size, date))
|
||||
}
|
||||
val query = ContentResolverQuery(
|
||||
uri = uriAvoidance.externalContentUri,
|
||||
projection = projection,
|
||||
selection = selection,
|
||||
selectionArgs = listOf(bucketId, "%image/svg%"),
|
||||
sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC",
|
||||
)
|
||||
|
||||
contentResolver.reduce(query) { cursor ->
|
||||
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
|
||||
val uri = uriAvoidance.uriAppender(query.uri, rowId)
|
||||
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
|
||||
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
|
||||
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
|
||||
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
|
||||
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
|
||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
|
||||
Media(rowId, uri, mimetype, width, height, size, date)
|
||||
}
|
||||
media
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,7 +54,6 @@ class FetchMediaUseCase(private val contentResolver: ContentResolver, private va
|
||||
|
||||
private fun getHeightColumn(orientation: Int) =
|
||||
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
|
||||
|
||||
}
|
||||
|
||||
data class Media(
|
||||
|
@ -23,9 +23,9 @@ import kotlinx.parcelize.Parcelize
|
||||
class ImageGalleryActivity : DapkActivity() {
|
||||
|
||||
private val module by unsafeLazy { module<ImageGalleryModule>() }
|
||||
private val viewModel by viewModel {
|
||||
private val imageGalleryState by state {
|
||||
val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload
|
||||
module.imageGalleryViewModel(payload!!.roomName)
|
||||
module.imageGalleryState(payload!!.roomName)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -42,7 +42,7 @@ class ImageGalleryActivity : DapkActivity() {
|
||||
setContent {
|
||||
Surface {
|
||||
PermissionGuard(permissionState) {
|
||||
ImageGalleryScreen(viewModel, onTopLevelBack = { finish() }) { media ->
|
||||
ImageGalleryScreen(imageGalleryState, onTopLevelBack = { finish() }) { media ->
|
||||
setResult(RESULT_OK, Intent().setData(media.uri))
|
||||
finish()
|
||||
}
|
||||
|
@ -1,18 +1,31 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.provider.MediaStore
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.core.createStateViewModel
|
||||
import app.dapk.st.messenger.gallery.state.ImageGalleryState
|
||||
import app.dapk.st.messenger.gallery.state.imageGalleryReducer
|
||||
|
||||
class ImageGalleryModule(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun imageGalleryViewModel(roomName: String) = ImageGalleryViewModel(
|
||||
FetchMediaFoldersUseCase(contentResolver, dispatchers),
|
||||
FetchMediaUseCase(contentResolver, dispatchers),
|
||||
roomName = roomName,
|
||||
)
|
||||
fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel {
|
||||
val uriAvoidance = MediaUriAvoidance(
|
||||
uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) },
|
||||
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
)
|
||||
imageGalleryReducer(
|
||||
roomName = roomName,
|
||||
FetchMediaFoldersUseCase(contentResolver, uriAvoidance, dispatchers),
|
||||
FetchMediaUseCase(contentResolver, uriAvoidance, dispatchers),
|
||||
JobBag(),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -21,38 +21,45 @@ import androidx.compose.ui.unit.sp
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.LifecycleEffect
|
||||
import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.design.components.GenericError
|
||||
import app.dapk.st.design.components.Spider
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.messenger.gallery.state.ImageGalleryActions
|
||||
import app.dapk.st.messenger.gallery.state.ImageGalleryPage
|
||||
import app.dapk.st.messenger.gallery.state.ImageGalleryState
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
|
||||
fun ImageGalleryScreen(state: ImageGalleryState, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
|
||||
LifecycleEffect(onStart = {
|
||||
viewModel.start()
|
||||
state.dispatch(ImageGalleryActions.Visible)
|
||||
})
|
||||
|
||||
val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = {
|
||||
when (it) {
|
||||
null -> onTopLevelBack()
|
||||
else -> viewModel.goTo(it)
|
||||
else -> state.dispatch(PageAction.GoTo(it))
|
||||
}
|
||||
}
|
||||
|
||||
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
|
||||
Spider(currentPage = state.current.state1.page, onNavigate = onNavigate) {
|
||||
item(ImageGalleryPage.Routes.folders) {
|
||||
ImageGalleryFolders(it, onClick = { viewModel.selectFolder(it) }, onRetry = { viewModel.start() })
|
||||
ImageGalleryFolders(
|
||||
it,
|
||||
onClick = { state.dispatch(ImageGalleryActions.SelectFolder(it)) },
|
||||
onRetry = { state.dispatch(ImageGalleryActions.Visible) }
|
||||
)
|
||||
}
|
||||
item(ImageGalleryPage.Routes.files) {
|
||||
ImageGalleryMedia(it, onImageSelected, onRetry = { viewModel.selectFolder(it.folder) })
|
||||
ImageGalleryMedia(it, onImageSelected, onRetry = { state.dispatch(ImageGalleryActions.SelectFolder(it.folder)) })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit, onRetry: () -> Unit) {
|
||||
private fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit, onRetry: () -> Unit) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
|
||||
val gradient = Brush.verticalGradient(
|
||||
@ -108,7 +115,7 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit, onRetry: () -> Unit) {
|
||||
private fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit, onRetry: () -> Unit) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
|
||||
Column {
|
||||
|
@ -1,89 +0,0 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.Route
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ImageGalleryViewModel(
|
||||
private val foldersUseCase: FetchMediaFoldersUseCase,
|
||||
private val fetchMediaUseCase: FetchMediaUseCase,
|
||||
roomName: String,
|
||||
) : DapkViewModel<ImageGalleryState, ImageGalleryEvent>(
|
||||
initialState = ImageGalleryState(
|
||||
page = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.folders,
|
||||
label = "Send to $roomName",
|
||||
parent = null,
|
||||
state = ImageGalleryPage.Folders(Lce.Loading())
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
||||
private var currentPageJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
currentPageJob?.cancel()
|
||||
currentPageJob = viewModelScope.launch {
|
||||
val folders = foldersUseCase.fetchFolders()
|
||||
updatePageState<ImageGalleryPage.Folders> { copy(content = Lce.Content(folders)) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun goTo(page: SpiderPage<out ImageGalleryPage>) {
|
||||
currentPageJob?.cancel()
|
||||
updateState { copy(page = page) }
|
||||
}
|
||||
|
||||
fun selectFolder(folder: Folder) {
|
||||
currentPageJob?.cancel()
|
||||
|
||||
updateState {
|
||||
copy(
|
||||
page = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.files,
|
||||
label = page.label,
|
||||
parent = ImageGalleryPage.Routes.folders,
|
||||
state = ImageGalleryPage.Files(Lce.Loading(), folder)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
currentPageJob = viewModelScope.launch {
|
||||
val media = fetchMediaUseCase.getMediaInBucket(folder.bucketId)
|
||||
updatePageState<ImageGalleryPage.Files> {
|
||||
copy(content = Lce.Content(media))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified S : ImageGalleryPage> updatePageState(crossinline block: S.() -> S) {
|
||||
val page = state.page
|
||||
val currentState = page.state
|
||||
require(currentState is S)
|
||||
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class ImageGalleryState(
|
||||
val page: SpiderPage<out ImageGalleryPage>,
|
||||
)
|
||||
|
||||
|
||||
sealed interface ImageGalleryPage {
|
||||
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
|
||||
data class Files(val content: Lce<List<Media>>, val folder: Folder) : ImageGalleryPage
|
||||
|
||||
object Routes {
|
||||
val folders = Route<Folders>("Folders")
|
||||
val files = Route<Files>("Files")
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ImageGalleryEvent
|
@ -0,0 +1,8 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
class MediaUriAvoidance(
|
||||
val uriAppender: (Uri, Long) -> Uri,
|
||||
val externalContentUri: Uri,
|
||||
)
|
@ -0,0 +1,9 @@
|
||||
package app.dapk.st.messenger.gallery.state
|
||||
|
||||
import app.dapk.st.messenger.gallery.Folder
|
||||
import app.dapk.state.Action
|
||||
|
||||
sealed interface ImageGalleryActions : Action {
|
||||
object Visible : ImageGalleryActions
|
||||
data class SelectFolder(val folder: Folder) : ImageGalleryActions
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
package app.dapk.st.messenger.gallery.state
|
||||
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.core.page.PageStateChange
|
||||
import app.dapk.st.core.page.createPageReducer
|
||||
import app.dapk.st.core.page.withPageContext
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.messenger.gallery.FetchMediaFoldersUseCase
|
||||
import app.dapk.st.messenger.gallery.FetchMediaUseCase
|
||||
import app.dapk.state.async
|
||||
import app.dapk.state.createReducer
|
||||
import app.dapk.state.sideEffect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
fun imageGalleryReducer(
|
||||
roomName: String,
|
||||
foldersUseCase: FetchMediaFoldersUseCase,
|
||||
fetchMediaUseCase: FetchMediaUseCase,
|
||||
jobBag: JobBag,
|
||||
) = createPageReducer(
|
||||
initialPage = SpiderPage<ImageGalleryPage>(
|
||||
route = ImageGalleryPage.Routes.folders,
|
||||
label = "Send to $roomName",
|
||||
parent = null,
|
||||
state = ImageGalleryPage.Folders(Lce.Loading())
|
||||
),
|
||||
factory = {
|
||||
createReducer(
|
||||
initialState = Unit,
|
||||
|
||||
async(ImageGalleryActions.Visible::class) {
|
||||
jobBag.replace(ImageGalleryPage.Folders::class, coroutineScope.launch {
|
||||
val folders = foldersUseCase.fetchFolders()
|
||||
withPageContext<ImageGalleryPage.Folders> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(content = Lce.Content(folders))))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async(ImageGalleryActions.SelectFolder::class) { action ->
|
||||
val page = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.files,
|
||||
label = rawPage().label,
|
||||
parent = ImageGalleryPage.Routes.folders,
|
||||
state = ImageGalleryPage.Files(Lce.Loading(), action.folder)
|
||||
)
|
||||
dispatch(PageAction.GoTo(page))
|
||||
|
||||
jobBag.replace(ImageGalleryPage.Files::class, coroutineScope.launch {
|
||||
val media = fetchMediaUseCase.getMediaInBucket(action.folder.bucketId)
|
||||
withPageContext<ImageGalleryPage.Files> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(content = Lce.Content(media))))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
sideEffect(PageStateChange.ChangePage::class) { action, _ ->
|
||||
jobBag.cancel(action.previous.state::class)
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
@ -0,0 +1,21 @@
|
||||
package app.dapk.st.messenger.gallery.state
|
||||
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.State
|
||||
import app.dapk.st.design.components.Route
|
||||
import app.dapk.st.messenger.gallery.Folder
|
||||
import app.dapk.st.messenger.gallery.Media
|
||||
import app.dapk.st.core.page.PageContainer
|
||||
import app.dapk.state.Combined2
|
||||
|
||||
typealias ImageGalleryState = State<Combined2<PageContainer<ImageGalleryPage>, Unit>, Unit>
|
||||
|
||||
sealed interface ImageGalleryPage {
|
||||
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
|
||||
data class Files(val content: Lce<List<Media>>, val folder: Folder) : ImageGalleryPage
|
||||
|
||||
object Routes {
|
||||
val folders = Route<Folders>("Folders")
|
||||
val files = Route<Files>("Files")
|
||||
}
|
||||
}
|
@ -9,16 +9,11 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.dapk.st.core.DapkActivity
|
||||
import app.dapk.st.core.module
|
||||
import app.dapk.st.core.viewModel
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.messenger.MessengerModule
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
class RoomSettingsActivity : DapkActivity() {
|
||||
|
||||
private val viewModel by viewModel { module<MessengerModule>().messengerViewModel() }
|
||||
|
||||
companion object {
|
||||
fun newInstance(context: Context, roomId: RoomId): Intent {
|
||||
return Intent(context, RoomSettingsActivity::class.java).apply {
|
||||
|
@ -0,0 +1,44 @@
|
||||
package app.dapk.st.messenger.state
|
||||
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.engine.MessengerPageState
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.state.Action
|
||||
|
||||
sealed interface ScreenAction : Action {
|
||||
data class CopyToClipboard(val model: BubbleModel) : ScreenAction
|
||||
object SendMessage : ScreenAction
|
||||
object OpenGalleryPicker : ScreenAction
|
||||
|
||||
sealed interface Notifications : ScreenAction {
|
||||
object Mute : Notifications
|
||||
object Unmute : Notifications
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ComponentLifecycle : Action {
|
||||
object Visible : ComponentLifecycle
|
||||
object Gone : ComponentLifecycle
|
||||
}
|
||||
|
||||
sealed interface MessagesStateChange : Action {
|
||||
data class Content(val content: MessengerPageState) : MessagesStateChange
|
||||
data class MuteContent(val isMuted: Boolean) : MessagesStateChange
|
||||
}
|
||||
|
||||
sealed interface ComposerStateChange : Action {
|
||||
data class SelectAttachmentToSend(val newValue: MessageAttachment) : ComposerStateChange
|
||||
data class TextUpdate(val newValue: String) : ComposerStateChange
|
||||
object Clear : ComposerStateChange
|
||||
|
||||
sealed interface ReplyMode : ComposerStateChange {
|
||||
data class Enter(val replyingTo: RoomEvent) : ReplyMode
|
||||
object Exit : ReplyMode
|
||||
}
|
||||
|
||||
sealed interface ImagePreview : ComposerStateChange {
|
||||
data class Show(val image: BubbleModel.Image) : ImagePreview
|
||||
object Hide : ImagePreview
|
||||
}
|
||||
}
|
@ -0,0 +1,203 @@
|
||||
package app.dapk.st.messenger.state
|
||||
|
||||
import android.os.Build
|
||||
import app.dapk.st.core.DeviceMeta
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.asString
|
||||
import app.dapk.st.core.extensions.takeIfContent
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.engine.MessengerPageState
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.engine.SendMessage
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.messenger.CopyToClipboard
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import app.dapk.state.*
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
internal fun messengerReducer(
|
||||
jobBag: JobBag,
|
||||
chatEngine: ChatEngine,
|
||||
copyToClipboard: CopyToClipboard,
|
||||
deviceMeta: DeviceMeta,
|
||||
messageOptionsStore: MessageOptionsStore,
|
||||
roomId: RoomId,
|
||||
initialAttachments: List<MessageAttachment>?,
|
||||
eventEmitter: suspend (MessengerEvent) -> Unit,
|
||||
): ReducerFactory<MessengerScreenState> {
|
||||
return createReducer(
|
||||
initialState = MessengerScreenState(
|
||||
roomId = roomId,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = initialComposerState(initialAttachments),
|
||||
viewerState = null,
|
||||
),
|
||||
|
||||
async(ComponentLifecycle::class) { action ->
|
||||
val state = getState()
|
||||
when (action) {
|
||||
is ComponentLifecycle.Visible -> {
|
||||
jobBag.replace(
|
||||
"messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled())
|
||||
.onEach { dispatch(MessagesStateChange.Content(it)) }
|
||||
.launchIn(coroutineScope)
|
||||
)
|
||||
}
|
||||
|
||||
ComponentLifecycle.Gone -> jobBag.cancel("messages")
|
||||
}
|
||||
},
|
||||
|
||||
change(MessagesStateChange.Content::class) { action, state ->
|
||||
state.copy(roomState = Lce.Content(action.content))
|
||||
},
|
||||
|
||||
change(ComposerStateChange.SelectAttachmentToSend::class) { action, state ->
|
||||
state.copy(
|
||||
composerState = ComposerState.Attachments(
|
||||
listOf(action.newValue),
|
||||
state.composerState.reply,
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
change(ComposerStateChange.ImagePreview::class) { action, state ->
|
||||
when (action) {
|
||||
is ComposerStateChange.ImagePreview.Show -> state.copy(viewerState = ViewerState(action.image))
|
||||
ComposerStateChange.ImagePreview.Hide -> state.copy(viewerState = null)
|
||||
}
|
||||
},
|
||||
|
||||
change(ComposerStateChange.TextUpdate::class) { action, state ->
|
||||
state.copy(composerState = ComposerState.Text(action.newValue, state.composerState.reply))
|
||||
},
|
||||
|
||||
change(ComposerStateChange.Clear::class) { _, state ->
|
||||
state.copy(composerState = ComposerState.Text("", reply = null))
|
||||
},
|
||||
|
||||
change(ComposerStateChange.ReplyMode::class) { action, state ->
|
||||
when (action) {
|
||||
is ComposerStateChange.ReplyMode.Enter -> state.copy(
|
||||
composerState = when (state.composerState) {
|
||||
is ComposerState.Attachments -> state.composerState.copy(reply = action.replyingTo)
|
||||
is ComposerState.Text -> state.composerState.copy(reply = action.replyingTo)
|
||||
}
|
||||
)
|
||||
|
||||
ComposerStateChange.ReplyMode.Exit -> state.copy(
|
||||
composerState = when (state.composerState) {
|
||||
is ComposerState.Attachments -> state.composerState.copy(reply = null)
|
||||
is ComposerState.Text -> state.composerState.copy(reply = null)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
sideEffect(ScreenAction.CopyToClipboard::class) { action, state ->
|
||||
when (val result = action.model.findCopyableContent()) {
|
||||
is CopyableResult.Content -> {
|
||||
copyToClipboard.copy(result.value)
|
||||
if (deviceMeta.apiVersion <= Build.VERSION_CODES.S_V2) {
|
||||
eventEmitter.invoke(MessengerEvent.Toast("Copied to clipboard"))
|
||||
}
|
||||
}
|
||||
|
||||
CopyableResult.NothingToCopy -> eventEmitter.invoke(MessengerEvent.Toast("Nothing to copy"))
|
||||
}
|
||||
},
|
||||
|
||||
sideEffect(ScreenAction.OpenGalleryPicker::class) { _, _ ->
|
||||
eventEmitter.invoke(MessengerEvent.SelectImageAttachment)
|
||||
},
|
||||
|
||||
async(ScreenAction.SendMessage::class) {
|
||||
val state = getState()
|
||||
when (val composerState = state.composerState) {
|
||||
is ComposerState.Text -> {
|
||||
dispatch(ComposerStateChange.Clear)
|
||||
state.roomState.takeIfContent()?.let { content ->
|
||||
chatEngine.sendTextMessage(content, composerState)
|
||||
}
|
||||
}
|
||||
|
||||
is ComposerState.Attachments -> {
|
||||
dispatch(ComposerStateChange.Clear)
|
||||
state.roomState.takeIfContent()?.let { content ->
|
||||
val roomState = content.roomState
|
||||
chatEngine.send(SendMessage.ImageMessage(uri = composerState.values.first().uri.value), roomState.roomOverview)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
change(MessagesStateChange.MuteContent::class) { action, state ->
|
||||
when (val roomState = state.roomState) {
|
||||
is Lce.Content -> state.copy(roomState = roomState.copy(value = roomState.value.copy(isMuted = action.isMuted)))
|
||||
is Lce.Error -> state
|
||||
is Lce.Loading -> state
|
||||
}
|
||||
},
|
||||
|
||||
async(ScreenAction.Notifications::class) { action ->
|
||||
when (action) {
|
||||
ScreenAction.Notifications.Mute -> chatEngine.muteRoom(roomId)
|
||||
ScreenAction.Notifications.Unmute -> chatEngine.unmuteRoom(roomId)
|
||||
}
|
||||
|
||||
dispatch(
|
||||
MessagesStateChange.MuteContent(
|
||||
isMuted = when (action) {
|
||||
ScreenAction.Notifications.Mute -> true
|
||||
ScreenAction.Notifications.Unmute -> false
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ChatEngine.sendTextMessage(content: MessengerPageState, composerState: ComposerState.Text) {
|
||||
val roomState = content.roomState
|
||||
val message = SendMessage.TextMessage(
|
||||
content = composerState.value,
|
||||
reply = composerState.reply?.toSendMessageReply(),
|
||||
)
|
||||
this.send(message = message, room = roomState.roomOverview)
|
||||
}
|
||||
|
||||
private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply(
|
||||
author = this.author,
|
||||
originalMessage = when (this) {
|
||||
is RoomEvent.Image -> TODO()
|
||||
is RoomEvent.Reply -> TODO()
|
||||
is RoomEvent.Redacted -> TODO()
|
||||
is RoomEvent.Message -> this.content.asString()
|
||||
is RoomEvent.Encrypted -> error("Should never happen")
|
||||
},
|
||||
eventId = this.eventId,
|
||||
timestampUtc = this.utcTimestamp,
|
||||
)
|
||||
|
||||
private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { ComposerState.Attachments(it, null) }
|
||||
?: ComposerState.Text(value = "", reply = null)
|
||||
|
||||
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {
|
||||
is BubbleModel.Encrypted -> CopyableResult.NothingToCopy
|
||||
is BubbleModel.Redacted -> CopyableResult.NothingToCopy
|
||||
is BubbleModel.Image -> CopyableResult.NothingToCopy
|
||||
is BubbleModel.Reply -> this.reply.findCopyableContent()
|
||||
is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content.asString()))
|
||||
}
|
||||
|
||||
private sealed interface CopyableResult {
|
||||
object NothingToCopy : CopyableResult
|
||||
data class Content(val value: CopyToClipboard.Copyable) : CopyableResult
|
||||
}
|
@ -1,17 +1,20 @@
|
||||
package app.dapk.st.messenger
|
||||
package app.dapk.st.messenger.state
|
||||
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.State
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.engine.MessengerState
|
||||
import app.dapk.st.engine.MessengerPageState
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
|
||||
typealias MessengerState = State<MessengerScreenState, MessengerEvent>
|
||||
|
||||
data class MessengerScreenState(
|
||||
val roomId: RoomId?,
|
||||
val roomState: Lce<MessengerState>,
|
||||
val roomId: RoomId,
|
||||
val roomState: Lce<MessengerPageState>,
|
||||
val composerState: ComposerState,
|
||||
val viewerState: ViewerState?
|
||||
val viewerState: ViewerState?,
|
||||
)
|
||||
|
||||
data class ViewerState(
|
@ -0,0 +1,359 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
import android.os.Build
|
||||
import app.dapk.st.core.*
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
import app.dapk.st.engine.RoomState
|
||||
import app.dapk.st.engine.SendMessage
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.messenger.state.*
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import fake.FakeChatEngine
|
||||
import fake.FakeJobBag
|
||||
import fake.FakeMessageOptionsStore
|
||||
import fixture.*
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
import test.ReducerTestScope
|
||||
import test.delegateReturn
|
||||
import test.expect
|
||||
import test.testReducer
|
||||
|
||||
private const val READ_RECEIPTS_ARE_DISABLED = true
|
||||
private val A_ROOM_ID = aRoomId("messenger state room id")
|
||||
private const val A_MESSAGE_CONTENT = "message content"
|
||||
private val AN_EVENT_ID = anEventId("state event")
|
||||
private val A_SELF_ID = aUserId("self")
|
||||
private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||
private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image)
|
||||
private val A_REPLY = aRoomReplyMessageEvent()
|
||||
private val AN_IMAGE_BUBBLE = BubbleModel.Image(
|
||||
BubbleModel.Image.ImageContent(100, 200, "a-url"),
|
||||
mockk(),
|
||||
BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27")
|
||||
)
|
||||
|
||||
private val A_TEXT_BUBBLE = BubbleModel.Text(
|
||||
content = RichText(listOf(RichText.Part.Normal(A_MESSAGE_CONTENT))),
|
||||
BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27")
|
||||
)
|
||||
|
||||
class MessengerReducerTest {
|
||||
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeCopyToClipboard = FakeCopyToClipboard()
|
||||
private val fakeDeviceMeta = FakeDeviceMeta()
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
|
||||
private val runReducerTest = testReducer { fakeEventSource ->
|
||||
messengerReducer(
|
||||
fakeJobBag.instance,
|
||||
fakeChatEngine,
|
||||
fakeCopyToClipboard.instance,
|
||||
fakeDeviceMeta.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
A_ROOM_ID,
|
||||
emptyList(),
|
||||
fakeEventSource,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given empty initial attachments, then initial state is loading with text composer`() = reducerWithInitialState(initialAttachments = emptyList()) {
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = A_ROOM_ID,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given null initial attachments, then initial state is loading with text composer`() = reducerWithInitialState(initialAttachments = null) {
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = A_ROOM_ID,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given initial attachments, then initial state is loading attachment composer`() = reducerWithInitialState(listOf(A_MESSAGE_ATTACHMENT)) {
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = A_ROOM_ID,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.replace("messages", any()) }
|
||||
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
|
||||
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
|
||||
|
||||
reduce(ComponentLifecycle.Visible)
|
||||
|
||||
assertOnlyDispatches(listOf(MessagesStateChange.Content(state)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Gone, then cancels sync job`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.cancel("messages") }
|
||||
|
||||
reduce(ComponentLifecycle.Gone)
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Content StateChange, then updates room state`() = runReducerTest {
|
||||
reduce(MessagesStateChange.Content(A_MESSENGER_PAGE_STATE))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(roomState = Lce.Content(A_MESSENGER_PAGE_STATE))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when SelectAttachmentToSend, then updates composer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.SelectAttachmentToSend(A_MESSAGE_ATTACHMENT))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Show ImagePreview, then updates viewer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.ImagePreview.Show(AN_IMAGE_BUBBLE))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(viewerState = ViewerState(AN_IMAGE_BUBBLE))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Hide ImagePreview, then updates viewer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.ImagePreview.Hide)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(viewerState = null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when TextUpdate StateChange, then updates composer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.TextUpdate(A_MESSAGE_CONTENT))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Clear ComposerStateChange, then clear composer state`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null)) }
|
||||
|
||||
reduce(ComposerStateChange.Clear)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = ComposerState.Text(value = "", reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given text composer, when Enter ReplyMode, then updates composer state with reply`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Enter(A_REPLY))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Text).copy(reply = A_REPLY))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given text composer, when Exit ReplyMode, then updates composer state`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = A_REPLY)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Exit)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Text).copy(reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given attachment composer, when Enter ReplyMode, then updates composer state with reply`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Enter(A_REPLY))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Attachments).copy(reply = A_REPLY))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given attachment composer, when Exit ReplyMode, then updates composer state`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = A_REPLY)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Exit)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Attachments).copy(reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when OpenGalleryPicker, then emits event`() = runReducerTest {
|
||||
reduce(ScreenAction.OpenGalleryPicker)
|
||||
|
||||
assertOnlyEvents(listOf(MessengerEvent.SelectImageAttachment))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given android api is lower than S_v2 and has text content, when CopyToClipboard, then copies to system and toasts`() = runReducerTest {
|
||||
fakeDeviceMeta.givenApiVersion().returns(Build.VERSION_CODES.S)
|
||||
fakeCopyToClipboard.instance.expect { it.copy(CopyToClipboard.Copyable.Text(A_MESSAGE_CONTENT)) }
|
||||
|
||||
reduce(ScreenAction.CopyToClipboard(A_TEXT_BUBBLE))
|
||||
|
||||
assertEvents(listOf(MessengerEvent.Toast("Copied to clipboard")))
|
||||
assertNoDispatches()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given android api is higher than S_v2 and has text content, when CopyToClipboard, then copies to system and does not toast`() = runReducerTest {
|
||||
fakeDeviceMeta.givenApiVersion().returns(Build.VERSION_CODES.TIRAMISU)
|
||||
fakeCopyToClipboard.instance.expect { it.copy(CopyToClipboard.Copyable.Text(A_MESSAGE_CONTENT)) }
|
||||
|
||||
reduce(ScreenAction.CopyToClipboard(A_TEXT_BUBBLE))
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given image content, when CopyToClipboard, then toasts nothing to copy`() = runReducerTest {
|
||||
reduce(ScreenAction.CopyToClipboard(AN_IMAGE_BUBBLE))
|
||||
|
||||
assertEvents(listOf(MessengerEvent.Toast("Nothing to copy")))
|
||||
assertNoDispatches()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given text composer, when SendMessage, then clear composer and sends text message`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null), roomState = Lce.Content(A_MESSENGER_PAGE_STATE)) }
|
||||
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
|
||||
|
||||
reduce(ScreenAction.SendMessage)
|
||||
|
||||
assertDispatches(listOf(ComposerStateChange.Clear))
|
||||
assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given text composer with reply, when SendMessage, then clear composer and sends text message`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = A_REPLY.message), roomState = Lce.Content(A_MESSENGER_PAGE_STATE)) }
|
||||
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT, reply = A_REPLY.message), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
|
||||
|
||||
reduce(ScreenAction.SendMessage)
|
||||
|
||||
assertDispatches(listOf(ComposerStateChange.Clear))
|
||||
assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given attachment composer, when SendMessage, then clear composer and sends image message`() = runReducerTest {
|
||||
setState {
|
||||
it.copy(
|
||||
composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null),
|
||||
roomState = Lce.Content(A_MESSENGER_PAGE_STATE)
|
||||
)
|
||||
}
|
||||
fakeChatEngine.expectUnit { it.send(expectImageMessage(A_MESSAGE_ATTACHMENT.uri), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
|
||||
|
||||
reduce(ScreenAction.SendMessage)
|
||||
|
||||
assertDispatches(listOf(ComposerStateChange.Clear))
|
||||
assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
private fun expectTextMessage(messageContent: String, reply: RoomEvent? = null): SendMessage.TextMessage {
|
||||
return SendMessage.TextMessage(messageContent, reply = reply?.toSendMessageReply())
|
||||
}
|
||||
|
||||
private fun expectImageMessage(uri: AndroidUri): SendMessage.ImageMessage {
|
||||
return SendMessage.ImageMessage(uri.value)
|
||||
}
|
||||
|
||||
private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply(
|
||||
author = this.author,
|
||||
originalMessage = when (this) {
|
||||
is RoomEvent.Image -> TODO()
|
||||
is RoomEvent.Reply -> TODO()
|
||||
is RoomEvent.Redacted -> TODO()
|
||||
is RoomEvent.Message -> this.content.asString()
|
||||
is RoomEvent.Encrypted -> error("Should never happen")
|
||||
},
|
||||
eventId = this.eventId,
|
||||
timestampUtc = this.utcTimestamp,
|
||||
)
|
||||
|
||||
private fun reducerWithInitialState(
|
||||
initialAttachments: List<MessageAttachment>?,
|
||||
block: suspend ReducerTestScope<MessengerScreenState, MessengerEvent>.() -> Unit
|
||||
) = testReducer { fakeEventSource ->
|
||||
messengerReducer(
|
||||
fakeJobBag.instance,
|
||||
fakeChatEngine,
|
||||
fakeCopyToClipboard.instance,
|
||||
fakeDeviceMeta.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
A_ROOM_ID,
|
||||
initialAttachments = initialAttachments,
|
||||
fakeEventSource,
|
||||
)
|
||||
}(block)
|
||||
|
||||
}
|
||||
|
||||
private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId)
|
||||
|
||||
private fun aRoomStateWithEventId(eventId: EventId): RoomState {
|
||||
val element = anEncryptedRoomMessageEvent(eventId = eventId, utcTimestamp = 1)
|
||||
return RoomState(aRoomOverview(roomId = A_ROOM_ID, isEncrypted = true), listOf(element))
|
||||
}
|
||||
|
||||
private fun RoomState.toMessengerState(selfId: UserId) = aMessengerState(self = selfId, roomState = this)
|
||||
|
||||
class FakeCopyToClipboard {
|
||||
val instance = mockk<CopyToClipboard>()
|
||||
}
|
||||
|
||||
class FakeDeviceMeta {
|
||||
val instance = mockk<DeviceMeta>()
|
||||
|
||||
fun givenApiVersion() = every { instance.apiVersion }.delegateReturn()
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
import ViewModelTest
|
||||
import app.dapk.st.core.DeviceMeta
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.extensions.takeIfContent
|
||||
import app.dapk.st.engine.MessengerState
|
||||
import app.dapk.st.engine.RoomState
|
||||
import app.dapk.st.engine.SendMessage
|
||||
import app.dapk.st.matrix.common.EventId
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
import fake.FakeChatEngine
|
||||
import fake.FakeMessageOptionsStore
|
||||
import fixture.*
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
|
||||
private const val READ_RECEIPTS_ARE_DISABLED = true
|
||||
private val A_ROOM_ID = aRoomId("messenger state room id")
|
||||
private const val A_MESSAGE_CONTENT = "message content"
|
||||
private val AN_EVENT_ID = anEventId("state event")
|
||||
private val A_SELF_ID = aUserId("self")
|
||||
|
||||
class MessengerViewModelTest {
|
||||
|
||||
private val runViewModelTest = ViewModelTest()
|
||||
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeCopyToClipboard = FakeCopyToClipboard()
|
||||
private val deviceMeta = DeviceMeta(26)
|
||||
|
||||
private val viewModel = MessengerViewModel(
|
||||
fakeChatEngine,
|
||||
fakeMessageOptionsStore.instance,
|
||||
fakeCopyToClipboard.instance,
|
||||
deviceMeta,
|
||||
factory = runViewModelTest.testMutableStateFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when creating view model, then initial state is loading room state`() = runViewModelTest {
|
||||
viewModel.test()
|
||||
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = null,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given timeline emits state, when starting, then updates state and marks room and events as read`() = runViewModelTest {
|
||||
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
|
||||
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
|
||||
|
||||
viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID, attachments = null))
|
||||
|
||||
assertStates<MessengerScreenState>(
|
||||
{ copy(roomId = A_ROOM_ID) },
|
||||
{ copy(roomState = Lce.Content(state)) },
|
||||
)
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when posting composer update, then updates state`() = runViewModelTest {
|
||||
viewModel.test().post(MessengerAction.ComposerTextUpdate(A_MESSAGE_CONTENT))
|
||||
|
||||
assertStates<MessengerScreenState>({
|
||||
copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null))
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given composer message state when posting send text, then resets composer state and sends message`() = runViewModelTest {
|
||||
val initialState = initialStateWithComposerMessage(A_ROOM_ID, A_MESSAGE_CONTENT)
|
||||
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT), initialState.roomState.takeIfContent()!!.roomState.roomOverview) }
|
||||
|
||||
viewModel.test(initialState = initialState).post(MessengerAction.ComposerSendText)
|
||||
|
||||
assertStates<MessengerScreenState>({ copy(composerState = ComposerState.Text("", reply = null)) })
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
private fun initialStateWithComposerMessage(roomId: RoomId, messageContent: String): MessengerScreenState {
|
||||
val roomState = RoomState(
|
||||
aRoomOverview(roomId = roomId, isEncrypted = true),
|
||||
listOf(anEncryptedRoomMessageEvent(utcTimestamp = 1))
|
||||
)
|
||||
return aMessageScreenState(roomId, aMessengerState(roomState = roomState), messageContent)
|
||||
}
|
||||
|
||||
private fun expectTextMessage(messageContent: String): SendMessage.TextMessage {
|
||||
return SendMessage.TextMessage(messageContent, reply = null)
|
||||
}
|
||||
|
||||
private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId)
|
||||
|
||||
private fun RoomState.toMessengerState(selfId: UserId) = aMessengerState(self = selfId, roomState = this)
|
||||
|
||||
private fun aRoomStateWithEventId(eventId: EventId): RoomState {
|
||||
val element = anEncryptedRoomMessageEvent(eventId = eventId, utcTimestamp = 1)
|
||||
return RoomState(aRoomOverview(roomId = A_ROOM_ID, isEncrypted = true), listOf(element))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
|
||||
roomId = roomId,
|
||||
roomState = Lce.Content(roomState),
|
||||
composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
|
||||
class FakeCopyToClipboard {
|
||||
val instance = mockk<CopyToClipboard>()
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import fake.CreateCursorScope
|
||||
import fake.FakeContentResolver
|
||||
import fake.FakeUri
|
||||
import fake.createCursor
|
||||
import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
|
||||
private val A_EXTERNAL_CONTENT_URI = FakeUri()
|
||||
private const val A_BUCKET_ID = "a-bucket-id"
|
||||
private const val A_SECOND_BUCKET_ID = "another-bucket"
|
||||
private const val A_DISPLAY_NAME = "a-bucket-name"
|
||||
private const val A_DATE_MODIFIED = 5000L
|
||||
|
||||
class FetchMediaFoldersUseCaseTest {
|
||||
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val fakeUriAppender = FakeUriAppender()
|
||||
private val uriAvoidance = MediaUriAvoidance(
|
||||
uriAppender = fakeUriAppender,
|
||||
externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
|
||||
)
|
||||
|
||||
private val useCase = FetchMediaFoldersUseCase(fakeContentResolver.instance, uriAvoidance, aCoroutineDispatchers())
|
||||
|
||||
@Test
|
||||
fun `given cursor content, when get folder, then reads unique folders`() = runTest {
|
||||
fakeContentResolver.givenFolderQuery().returns(createCursor {
|
||||
addFolderRow(rowId = 1, A_BUCKET_ID)
|
||||
addFolderRow(rowId = 2, A_BUCKET_ID)
|
||||
addFolderRow(rowId = 3, A_SECOND_BUCKET_ID)
|
||||
})
|
||||
|
||||
val result = useCase.fetchFolders()
|
||||
|
||||
result shouldBeEqualTo listOf(
|
||||
Folder(
|
||||
bucketId = A_BUCKET_ID,
|
||||
title = A_DISPLAY_NAME,
|
||||
thumbnail = fakeUriAppender.get(rowId = 1),
|
||||
),
|
||||
Folder(
|
||||
bucketId = A_SECOND_BUCKET_ID,
|
||||
title = A_DISPLAY_NAME,
|
||||
thumbnail = fakeUriAppender.get(rowId = 3),
|
||||
),
|
||||
)
|
||||
result[0].itemCount shouldBeEqualTo 2
|
||||
result[1].itemCount shouldBeEqualTo 1
|
||||
}
|
||||
|
||||
private fun CreateCursorScope.addFolderRow(rowId: Long, bucketId: String) {
|
||||
addRow(
|
||||
MediaStore.Images.Media._ID to rowId,
|
||||
MediaStore.Images.Media.BUCKET_ID to bucketId,
|
||||
MediaStore.Images.Media.BUCKET_DISPLAY_NAME to A_DISPLAY_NAME,
|
||||
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun FakeContentResolver.givenFolderQuery() = this.givenQueryResult(
|
||||
A_EXTERNAL_CONTENT_URI.instance,
|
||||
arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.BUCKET_ID,
|
||||
MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
|
||||
MediaStore.Images.Media.DATE_MODIFIED
|
||||
),
|
||||
"${isNotPending()} AND ${MediaStore.Images.Media.BUCKET_ID} AND ${MediaStore.Images.Media.MIME_TYPE} NOT LIKE ?",
|
||||
arrayOf("%image/svg%"),
|
||||
"${MediaStore.Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${MediaStore.Images.Media.DATE_MODIFIED} DESC",
|
||||
)
|
||||
|
||||
class FakeUriAppender : (Uri, Long) -> Uri {
|
||||
|
||||
private val uris = mutableMapOf<Long, FakeUri>()
|
||||
|
||||
override fun invoke(uri: Uri, rowId: Long): Uri {
|
||||
val fakeUri = FakeUri()
|
||||
uris[rowId] = fakeUri
|
||||
return fakeUri.instance
|
||||
}
|
||||
|
||||
fun get(rowId: Long) = uris[rowId]!!.instance
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.provider.MediaStore
|
||||
import fake.FakeContentResolver
|
||||
import fake.FakeUri
|
||||
import fake.createCursor
|
||||
import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
|
||||
private val A_EXTERNAL_CONTENT_URI = FakeUri()
|
||||
private val ROW_URI = FakeUri()
|
||||
private const val A_BUCKET_ID = "a-bucket-id"
|
||||
private const val A_ROW_ID = 20L
|
||||
private const val A_MIME_TYPE = "image/png"
|
||||
private const val A_DATE_MODIFIED = 5000L
|
||||
private const val A_SIZE = 1000L
|
||||
private const val A_NORMAL_ORIENTATION = 0
|
||||
private const val AN_INVERTED_ORIENTATION = 90
|
||||
private const val A_WIDTH = 250
|
||||
private const val A_HEIGHT = 750
|
||||
|
||||
class FetchMediaUseCaseTest {
|
||||
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val uriAvoidance = MediaUriAvoidance(
|
||||
uriAppender = { _, _ -> ROW_URI.instance },
|
||||
externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
|
||||
)
|
||||
|
||||
private val useCase = FetchMediaUseCase(fakeContentResolver.instance, uriAvoidance, aCoroutineDispatchers())
|
||||
|
||||
@Test
|
||||
fun `given cursor content, when get media for bucket, then reads media`() = runTest {
|
||||
fakeContentResolver.givenMediaQuery().returns(createCursor {
|
||||
addRow(
|
||||
MediaStore.Images.Media._ID to A_ROW_ID,
|
||||
MediaStore.Images.Media.MIME_TYPE to A_MIME_TYPE,
|
||||
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
|
||||
MediaStore.Images.Media.ORIENTATION to A_NORMAL_ORIENTATION,
|
||||
MediaStore.Images.Media.WIDTH to A_WIDTH,
|
||||
MediaStore.Images.Media.HEIGHT to A_HEIGHT,
|
||||
MediaStore.Images.Media.SIZE to A_SIZE,
|
||||
)
|
||||
})
|
||||
|
||||
val result = useCase.getMediaInBucket(A_BUCKET_ID)
|
||||
|
||||
result shouldBeEqualTo listOf(
|
||||
Media(
|
||||
id = A_ROW_ID,
|
||||
uri = ROW_URI.instance,
|
||||
mimeType = A_MIME_TYPE,
|
||||
width = A_WIDTH,
|
||||
height = A_HEIGHT,
|
||||
size = A_SIZE,
|
||||
dateModifiedEpochMillis = A_DATE_MODIFIED
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given cursor content with 90 degree orientation, when get media for bucket, then reads media with inverted width and height`() = runTest {
|
||||
fakeContentResolver.givenMediaQuery().returns(createCursor {
|
||||
addRow(
|
||||
MediaStore.Images.Media._ID to A_ROW_ID,
|
||||
MediaStore.Images.Media.MIME_TYPE to A_MIME_TYPE,
|
||||
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
|
||||
MediaStore.Images.Media.ORIENTATION to AN_INVERTED_ORIENTATION,
|
||||
MediaStore.Images.Media.WIDTH to A_WIDTH,
|
||||
MediaStore.Images.Media.HEIGHT to A_HEIGHT,
|
||||
MediaStore.Images.Media.SIZE to A_SIZE,
|
||||
)
|
||||
})
|
||||
|
||||
val result = useCase.getMediaInBucket(A_BUCKET_ID)
|
||||
|
||||
result shouldBeEqualTo listOf(
|
||||
Media(
|
||||
id = A_ROW_ID,
|
||||
uri = ROW_URI.instance,
|
||||
mimeType = A_MIME_TYPE,
|
||||
width = A_HEIGHT,
|
||||
height = A_WIDTH,
|
||||
size = A_SIZE,
|
||||
dateModifiedEpochMillis = A_DATE_MODIFIED
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun FakeContentResolver.givenMediaQuery() = this.givenQueryResult(
|
||||
A_EXTERNAL_CONTENT_URI.instance,
|
||||
arrayOf(
|
||||
MediaStore.Images.Media._ID,
|
||||
MediaStore.Images.Media.MIME_TYPE,
|
||||
MediaStore.Images.Media.DATE_MODIFIED,
|
||||
MediaStore.Images.Media.ORIENTATION,
|
||||
MediaStore.Images.Media.WIDTH,
|
||||
MediaStore.Images.Media.HEIGHT,
|
||||
MediaStore.Images.Media.SIZE
|
||||
),
|
||||
MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?",
|
||||
arrayOf(A_BUCKET_ID, "%image/svg%"),
|
||||
MediaStore.Images.Media.DATE_MODIFIED + " DESC",
|
||||
)
|
@ -0,0 +1,131 @@
|
||||
package app.dapk.st.messenger.gallery.state
|
||||
|
||||
import android.net.Uri
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.core.page.PageContainer
|
||||
import app.dapk.st.core.page.PageStateChange
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.messenger.gallery.FetchMediaFoldersUseCase
|
||||
import app.dapk.st.messenger.gallery.FetchMediaUseCase
|
||||
import app.dapk.st.messenger.gallery.Folder
|
||||
import app.dapk.st.messenger.gallery.Media
|
||||
import app.dapk.state.Combined2
|
||||
import fake.FakeJobBag
|
||||
import fake.FakeUri
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import test.assertOnlyDispatches
|
||||
import test.delegateReturn
|
||||
import test.expect
|
||||
import test.testReducer
|
||||
|
||||
private const val A_ROOM_NAME = "a room name"
|
||||
private val A_FOLDER = Folder(
|
||||
bucketId = "a-bucket-id",
|
||||
title = "a title",
|
||||
thumbnail = FakeUri().instance,
|
||||
)
|
||||
private val A_MEDIA_RESULT = listOf(aMedia())
|
||||
private val A_FOLDERS_RESULT = listOf(aFolder())
|
||||
private val AN_INITIAL_FILES_PAGE = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.files,
|
||||
label = "Send to $A_ROOM_NAME",
|
||||
parent = ImageGalleryPage.Routes.folders,
|
||||
state = ImageGalleryPage.Files(Lce.Loading(), A_FOLDER)
|
||||
)
|
||||
|
||||
private val AN_INITIAL_FOLDERS_PAGE = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.folders,
|
||||
label = "Send to $A_ROOM_NAME",
|
||||
parent = null,
|
||||
state = ImageGalleryPage.Folders(Lce.Loading())
|
||||
)
|
||||
|
||||
class ImageGalleryReducerTest {
|
||||
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
private val fakeFetchMediaFoldersUseCase = FakeFetchMediaFoldersUseCase()
|
||||
private val fakeFetchMediaUseCase = FakeFetchMediaUseCase()
|
||||
|
||||
private val runReducerTest = testReducer { _: (Unit) -> Unit ->
|
||||
imageGalleryReducer(
|
||||
A_ROOM_NAME,
|
||||
fakeFetchMediaFoldersUseCase.instance,
|
||||
fakeFetchMediaUseCase.instance,
|
||||
fakeJobBag.instance,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is folders page`() = runReducerTest {
|
||||
assertInitialState(pageState(AN_INITIAL_FOLDERS_PAGE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Visible, then updates Folders content`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.replace(ImageGalleryPage.Folders::class, any()) }
|
||||
fakeFetchMediaFoldersUseCase.givenFolders().returns(A_FOLDERS_RESULT)
|
||||
|
||||
reduce(ImageGalleryActions.Visible)
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(AN_INITIAL_FOLDERS_PAGE.state.copy(content = Lce.Content(A_FOLDERS_RESULT)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when SelectFolder, then goes to Folder page and fetches content`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.replace(ImageGalleryPage.Files::class, any()) }
|
||||
fakeFetchMediaUseCase.givenMedia(A_FOLDER.bucketId).returns(A_MEDIA_RESULT)
|
||||
val goToFolderPage = PageAction.GoTo(AN_INITIAL_FILES_PAGE)
|
||||
actionSideEffect(goToFolderPage) { pageState(goToFolderPage.page) }
|
||||
|
||||
reduce(ImageGalleryActions.SelectFolder(A_FOLDER))
|
||||
|
||||
assertOnlyDispatches(
|
||||
goToFolderPage,
|
||||
PageStateChange.UpdatePage(goToFolderPage.page.state.copy(content = Lce.Content(A_MEDIA_RESULT)))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when ChangePage, then cancels previous page jobs`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.cancel(ImageGalleryPage.Folders::class) }
|
||||
|
||||
reduce(PageStateChange.ChangePage(previous = AN_INITIAL_FOLDERS_PAGE, newPage = AN_INITIAL_FILES_PAGE))
|
||||
|
||||
assertOnlyStateChange(pageState(AN_INITIAL_FILES_PAGE))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <P> pageState(page: SpiderPage<out P>) = Combined2(PageContainer(page), Unit)
|
||||
|
||||
class FakeFetchMediaFoldersUseCase {
|
||||
val instance = mockk<FetchMediaFoldersUseCase>()
|
||||
|
||||
fun givenFolders() = coEvery { instance.fetchFolders() }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeFetchMediaUseCase {
|
||||
val instance = mockk<FetchMediaUseCase>()
|
||||
|
||||
fun givenMedia(bucketId: String) = coEvery { instance.getMediaInBucket(bucketId) }.delegateReturn()
|
||||
}
|
||||
|
||||
fun aMedia(
|
||||
id: Long = 1L,
|
||||
uri: Uri = FakeUri().instance,
|
||||
mimeType: String = "image/png",
|
||||
width: Int = 100,
|
||||
height: Int = 250,
|
||||
size: Long = 1000L,
|
||||
dateModifiedEpochMillis: Long = 5000L,
|
||||
) = Media(id, uri, mimeType, width, height, size, dateModifiedEpochMillis)
|
||||
|
||||
fun aFolder(
|
||||
bucketId: String = "a-bucket-id",
|
||||
title: String = "a title",
|
||||
thumbnail: Uri = FakeUri().instance,
|
||||
) = Folder(bucketId, title, thumbnail)
|
@ -1,5 +1,6 @@
|
||||
package app.dapk.st.notifications
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
@ -20,56 +21,30 @@ class NotificationChannels(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannelGroup(NotificationChannelGroup(CHATS_NOTIFICATION_GROUP_ID, "Chats"))
|
||||
|
||||
if (notificationManager.getNotificationChannel(DIRECT_CHANNEL_ID) == null) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
DIRECT_CHANNEL_ID,
|
||||
"Direct notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).also {
|
||||
it.enableVibration(true)
|
||||
it.enableLights(true)
|
||||
it.group = CHATS_NOTIFICATION_GROUP_ID
|
||||
}
|
||||
)
|
||||
notificationManager.createIfMissing(DIRECT_CHANNEL_ID) {
|
||||
createChannel(it, "Direct notifications", NotificationManager.IMPORTANCE_HIGH, CHATS_NOTIFICATION_GROUP_ID)
|
||||
}
|
||||
|
||||
if (notificationManager.getNotificationChannel(GROUP_CHANNEL_ID) == null) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
GROUP_CHANNEL_ID,
|
||||
"Group notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).also {
|
||||
it.group = CHATS_NOTIFICATION_GROUP_ID
|
||||
}
|
||||
)
|
||||
notificationManager.createIfMissing(GROUP_CHANNEL_ID) {
|
||||
createChannel(it, "Group notifications", NotificationManager.IMPORTANCE_HIGH, CHATS_NOTIFICATION_GROUP_ID)
|
||||
}
|
||||
|
||||
if (notificationManager.getNotificationChannel(INVITE_CHANNEL_ID) == null) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
INVITE_CHANNEL_ID,
|
||||
"Invite notifications",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).also {
|
||||
it.group = CHATS_NOTIFICATION_GROUP_ID
|
||||
}
|
||||
)
|
||||
notificationManager.createIfMissing(INVITE_CHANNEL_ID) {
|
||||
createChannel(it, "Invite notifications", NotificationManager.IMPORTANCE_DEFAULT, CHATS_NOTIFICATION_GROUP_ID)
|
||||
}
|
||||
|
||||
if (notificationManager.getNotificationChannel(SUMMARY_CHANNEL_ID) == null) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
SUMMARY_CHANNEL_ID,
|
||||
"Other notifications",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).also {
|
||||
it.group = CHATS_NOTIFICATION_GROUP_ID
|
||||
}
|
||||
)
|
||||
notificationManager.createIfMissing(SUMMARY_CHANNEL_ID) {
|
||||
createChannel(it, "Other notifications", NotificationManager.IMPORTANCE_DEFAULT, CHATS_NOTIFICATION_GROUP_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun createChannel(id: String, name: String, importance: Int, groupId: String) = NotificationChannel(id, name, importance)
|
||||
.also { it.group = groupId }
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun NotificationManager.createIfMissing(id: String, creator: (String) -> NotificationChannel) {
|
||||
if (this.getNotificationChannel(SUMMARY_CHANNEL_ID) == null) {
|
||||
this.createNotificationChannel(creator.invoke(id))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -15,6 +15,7 @@ class RoomEventsToNotifiableMapper {
|
||||
is RoomEvent.Message -> this.content.asString()
|
||||
is RoomEvent.Reply -> this.message.toNotifiableContent()
|
||||
is RoomEvent.Encrypted -> "Encrypted message"
|
||||
is RoomEvent.Redacted -> "Deleted message"
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import test.delegateReturn
|
||||
import test.expect
|
||||
import test.runExpectTest
|
||||
|
||||
private const val SUMMARY_ID = 101
|
||||
@ -45,12 +44,13 @@ class NotificationRendererTest {
|
||||
@Test
|
||||
fun `given removed rooms when rendering then cancels notifications with cancelled room ids`() = runExpectTest {
|
||||
val removedRooms = setOf(aRoomId("id-1"), aRoomId("id-2"))
|
||||
fakeNotificationFactory.instance.expect { it.mapToNotifications(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet())) }
|
||||
fakeNotificationManager.instance.expectUnit {
|
||||
removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) }
|
||||
}
|
||||
val state = aNotificationState(removedRooms = removedRooms)
|
||||
fakeNotificationFactory.givenNotifications(state).returns(aNotifications())
|
||||
fakeNotificationManager.instance.expectUnit { removedRooms.forEach { removedRoom -> it.cancel(removedRoom.value, ROOM_MESSAGE_ID) } }
|
||||
fakeNotificationManager.instance.expectUnit { it.cancel(SUMMARY_ID) }
|
||||
|
||||
notificationRenderer.render(state)
|
||||
|
||||
notificationRenderer.render(NotificationState(emptyMap(), removedRooms, emptySet(), emptySet()))
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
|
@ -104,7 +104,23 @@ class ProfileViewModel(
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
updateState { ProfileScreenState(SpiderPage(Page.Routes.profile, "Profile", null, Page.Profile(Lce.Loading()), hasToolbar = false)) }
|
||||
when (state.page.state) {
|
||||
is Page.Invitations -> updateState {
|
||||
ProfileScreenState(
|
||||
SpiderPage(
|
||||
Page.Routes.profile,
|
||||
"Profile",
|
||||
null,
|
||||
Page.Profile(Lce.Loading()),
|
||||
hasToolbar = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is Page.Profile -> {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
|
@ -15,6 +15,7 @@ dependencies {
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:store"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:state"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
|
||||
androidImportFixturesWorkaround(project, project(":chat-engine"))
|
||||
|
@ -4,20 +4,17 @@ import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.dapk.st.core.DapkActivity
|
||||
import app.dapk.st.core.module
|
||||
import app.dapk.st.core.resetModules
|
||||
import app.dapk.st.core.viewModel
|
||||
import app.dapk.st.core.*
|
||||
|
||||
class SettingsActivity : DapkActivity() {
|
||||
|
||||
private val settingsViewModel by viewModel { module<SettingsModule>().settingsViewModel() }
|
||||
private val settingsState by state { module<SettingsModule>().settingsState() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
SettingsScreen(settingsViewModel, onSignOut = {
|
||||
SettingsScreen(settingsState, onSignOut = {
|
||||
resetModules()
|
||||
navigator.navigate.toHome()
|
||||
finish()
|
||||
|
@ -8,6 +8,8 @@ import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.push.PushModule
|
||||
import app.dapk.st.settings.eventlogger.EventLoggerViewModel
|
||||
import app.dapk.st.settings.state.SettingsState
|
||||
import app.dapk.st.settings.state.settingsReducer
|
||||
|
||||
class SettingsModule(
|
||||
private val chatEngine: ChatEngine,
|
||||
@ -22,20 +24,25 @@ class SettingsModule(
|
||||
private val messageOptionsStore: MessageOptionsStore,
|
||||
) : ProvidableModule {
|
||||
|
||||
internal fun settingsViewModel(): SettingsViewModel {
|
||||
return SettingsViewModel(
|
||||
chatEngine,
|
||||
storeModule.cacheCleaner(),
|
||||
contentResolver,
|
||||
UriFilenameResolver(contentResolver, coroutineDispatchers),
|
||||
SettingsItemFactory(buildMeta, deviceMeta, pushModule.pushTokenRegistrars(), themeStore, loggingStore, messageOptionsStore),
|
||||
pushModule.pushTokenRegistrars(),
|
||||
themeStore,
|
||||
loggingStore,
|
||||
messageOptionsStore,
|
||||
)
|
||||
internal fun settingsState(): SettingsState {
|
||||
return createStateViewModel {
|
||||
settingsReducer(
|
||||
chatEngine,
|
||||
storeModule.cacheCleaner(),
|
||||
contentResolver,
|
||||
UriFilenameResolver(contentResolver, coroutineDispatchers),
|
||||
SettingsItemFactory(buildMeta, deviceMeta, pushModule.pushTokenRegistrars(), themeStore, loggingStore, messageOptionsStore),
|
||||
pushModule.pushTokenRegistrars(),
|
||||
themeStore,
|
||||
loggingStore,
|
||||
messageOptionsStore,
|
||||
it,
|
||||
JobBag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal fun eventLogViewModel(): EventLoggerViewModel {
|
||||
return EventLoggerViewModel(storeModule.eventLogStore())
|
||||
}
|
||||
|
@ -41,35 +41,44 @@ import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.core.components.Header
|
||||
import app.dapk.st.core.extensions.takeAs
|
||||
import app.dapk.st.core.getActivity
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.design.components.*
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.navigator.Navigator
|
||||
import app.dapk.st.settings.SettingsEvent.*
|
||||
import app.dapk.st.settings.eventlogger.EventLogActivity
|
||||
import app.dapk.st.settings.state.ComponentLifecycle
|
||||
import app.dapk.st.settings.state.RootActions
|
||||
import app.dapk.st.settings.state.ScreenAction
|
||||
import app.dapk.st.settings.state.SettingsState
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, navigator: Navigator) {
|
||||
viewModel.ObserveEvents(onSignOut)
|
||||
internal fun SettingsScreen(settingsState: SettingsState, onSignOut: () -> Unit, navigator: Navigator) {
|
||||
settingsState.ObserveEvents(onSignOut)
|
||||
LaunchedEffect(true) {
|
||||
viewModel.start()
|
||||
settingsState.dispatch(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
val onNavigate: (SpiderPage<out Page>?) -> Unit = {
|
||||
when (it) {
|
||||
null -> navigator.navigate.upToHome()
|
||||
else -> viewModel.goTo(it)
|
||||
else -> settingsState.dispatch(PageAction.GoTo(it))
|
||||
}
|
||||
}
|
||||
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
|
||||
Spider(currentPage = settingsState.current.state1.page, onNavigate = onNavigate) {
|
||||
item(Page.Routes.root) {
|
||||
RootSettings(it, onClick = { viewModel.onClick(it) }, onRetry = { viewModel.start() })
|
||||
RootSettings(
|
||||
it,
|
||||
onClick = { settingsState.dispatch(ScreenAction.OnClick(it)) },
|
||||
onRetry = { settingsState.dispatch(ComponentLifecycle.Visible) }
|
||||
)
|
||||
}
|
||||
item(Page.Routes.encryption) {
|
||||
Encryption(viewModel, it)
|
||||
Encryption(settingsState, it)
|
||||
}
|
||||
item(Page.Routes.pushProviders) {
|
||||
PushProviders(viewModel, it)
|
||||
PushProviders(settingsState, it)
|
||||
}
|
||||
item(Page.Routes.importRoomKeys) {
|
||||
when (val result = it.importProgress) {
|
||||
@ -83,7 +92,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
|
||||
it?.let {
|
||||
viewModel.fileSelected(it)
|
||||
settingsState.dispatch(RootActions.SelectKeysFile(it))
|
||||
}
|
||||
}
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
@ -100,7 +109,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
|
||||
var passwordVisibility by rememberSaveable { mutableStateOf(false) }
|
||||
val startImportAction = {
|
||||
keyboardController?.hide()
|
||||
viewModel.importFromFileKeys(it.selectedFile.uri, passphrase)
|
||||
settingsState.dispatch(RootActions.ImportKeysFromFile(it.selectedFile.uri, passphrase))
|
||||
}
|
||||
|
||||
TextField(
|
||||
@ -235,40 +244,40 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit, onRetr
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) {
|
||||
private fun Encryption(state: SettingsState, page: Page.Security) {
|
||||
Column {
|
||||
TextRow("Import room keys", includeDivider = false, onClick = { viewModel.goToImportRoom() })
|
||||
TextRow("Import room keys", includeDivider = false, onClick = { state.dispatch(ScreenAction.OpenImportRoom) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun PushProviders(viewModel: SettingsViewModel, state: Page.PushProviders) {
|
||||
private fun PushProviders(state: SettingsState, page: Page.PushProviders) {
|
||||
LaunchedEffect(true) {
|
||||
viewModel.fetchPushProviders()
|
||||
state.dispatch(RootActions.FetchProviders)
|
||||
}
|
||||
|
||||
when (val lce = state.options) {
|
||||
when (val lce = page.options) {
|
||||
null -> {}
|
||||
is Lce.Loading -> CenteredLoading()
|
||||
is Lce.Content -> {
|
||||
LazyColumn {
|
||||
items(lce.value) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = it == state.selection, onClick = { viewModel.selectPushProvider(it) })
|
||||
RadioButton(selected = it == page.selection, onClick = { state.dispatch(RootActions.SelectPushProvider(it)) })
|
||||
Text(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Lce.Error -> GenericError(cause = lce.cause) { viewModel.fetchPushProviders() }
|
||||
is Lce.Error -> GenericError(cause = lce.cause) { state.dispatch(RootActions.FetchProviders) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) {
|
||||
private fun SettingsState.ObserveEvents(onSignOut: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
StartObserving {
|
||||
this@ObserveEvents.events.launch {
|
||||
|
@ -1,182 +0,0 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.ThemeStore
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import app.dapk.st.domain.application.eventlog.LoggingStore
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.push.PushTokenRegistrars
|
||||
import app.dapk.st.push.Registrar
|
||||
import app.dapk.st.settings.SettingItem.Id.*
|
||||
import app.dapk.st.settings.SettingsEvent.*
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import app.dapk.st.viewmodel.MutableStateFactory
|
||||
import app.dapk.st.viewmodel.defaultStateFactory
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
|
||||
internal class SettingsViewModel(
|
||||
private val chatEngine: ChatEngine,
|
||||
private val cacheCleaner: StoreCleaner,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val uriFilenameResolver: UriFilenameResolver,
|
||||
private val settingsItemFactory: SettingsItemFactory,
|
||||
private val pushTokenRegistrars: PushTokenRegistrars,
|
||||
private val themeStore: ThemeStore,
|
||||
private val loggingStore: LoggingStore,
|
||||
private val messageOptionsStore: MessageOptionsStore,
|
||||
factory: MutableStateFactory<SettingsScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<SettingsScreenState, SettingsEvent>(
|
||||
initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))),
|
||||
factory = factory,
|
||||
) {
|
||||
|
||||
fun start() {
|
||||
viewModelScope.launch {
|
||||
val root = Page.Root(Lce.Content(settingsItemFactory.root()))
|
||||
val rootPage = SpiderPage(Page.Routes.root, "Settings", null, root)
|
||||
updateState { copy(page = rootPage) }
|
||||
}
|
||||
}
|
||||
|
||||
fun goTo(page: SpiderPage<out Page>) {
|
||||
updateState { copy(page = page) }
|
||||
}
|
||||
|
||||
fun onClick(item: SettingItem) {
|
||||
when (item.id) {
|
||||
SignOut -> viewModelScope.launch {
|
||||
cacheCleaner.cleanCache(removeCredentials = true)
|
||||
_events.emit(SignedOut)
|
||||
}
|
||||
|
||||
AccessToken -> viewModelScope.launch {
|
||||
require(item is SettingItem.AccessToken)
|
||||
_events.emit(CopyToClipboard("Token copied", item.accessToken))
|
||||
}
|
||||
|
||||
ClearCache -> viewModelScope.launch {
|
||||
cacheCleaner.cleanCache(removeCredentials = false)
|
||||
_events.emit(Toast(message = "Cache deleted"))
|
||||
}
|
||||
|
||||
EventLog -> viewModelScope.launch {
|
||||
_events.emit(OpenEventLog)
|
||||
}
|
||||
|
||||
Encryption -> {
|
||||
updateState {
|
||||
copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security))
|
||||
}
|
||||
}
|
||||
|
||||
PrivacyPolicy -> viewModelScope.launch {
|
||||
_events.emit(OpenUrl(PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
PushProvider -> {
|
||||
updateState {
|
||||
copy(page = SpiderPage(Page.Routes.pushProviders, "Push providers", Page.Routes.root, Page.PushProviders()))
|
||||
}
|
||||
}
|
||||
|
||||
Ignored -> {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
ToggleDynamicTheme -> viewModelScope.launch {
|
||||
themeStore.storeMaterialYouEnabled(!themeStore.isMaterialYouEnabled())
|
||||
refreshRoot()
|
||||
_events.emit(RecreateActivity)
|
||||
|
||||
}
|
||||
|
||||
ToggleEnableLogs -> viewModelScope.launch {
|
||||
loggingStore.setEnabled(!loggingStore.isEnabled())
|
||||
refreshRoot()
|
||||
}
|
||||
|
||||
ToggleSendReadReceipts -> viewModelScope.launch {
|
||||
messageOptionsStore.setReadReceiptsDisabled(!messageOptionsStore.isReadReceiptsDisabled())
|
||||
refreshRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshRoot() {
|
||||
start()
|
||||
}
|
||||
|
||||
fun fetchPushProviders() {
|
||||
updatePageState<Page.PushProviders> { copy(options = Lce.Loading()) }
|
||||
viewModelScope.launch {
|
||||
val currentSelection = pushTokenRegistrars.currentSelection()
|
||||
val options = pushTokenRegistrars.options()
|
||||
updatePageState<Page.PushProviders> {
|
||||
copy(
|
||||
selection = currentSelection,
|
||||
options = Lce.Content(options)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPushProvider(registrar: Registrar) {
|
||||
viewModelScope.launch {
|
||||
pushTokenRegistrars.makeSelection(registrar)
|
||||
fetchPushProviders()
|
||||
}
|
||||
}
|
||||
|
||||
fun importFromFileKeys(file: Uri, passphrase: String) {
|
||||
updatePageState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0)) }
|
||||
viewModelScope.launch {
|
||||
with(chatEngine) {
|
||||
runCatching { contentResolver.openInputStream(file)!! }
|
||||
.fold(
|
||||
onSuccess = { fileStream ->
|
||||
fileStream.importRoomKeys(passphrase)
|
||||
.onEach {
|
||||
updatePageState<Page.ImportRoomKey> { copy(importProgress = it) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
},
|
||||
onFailure = {
|
||||
updatePageState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToImportRoom() {
|
||||
goTo(SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey()))
|
||||
}
|
||||
|
||||
fun fileSelected(file: Uri) {
|
||||
viewModelScope.launch {
|
||||
val namedFile = NamedUri(
|
||||
name = uriFilenameResolver.readFilenameFromUri(file),
|
||||
uri = file
|
||||
)
|
||||
updatePageState<Page.ImportRoomKey> { copy(selectedFile = namedFile) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified S : Page> updatePageState(crossinline block: S.() -> S) {
|
||||
val page = state.page
|
||||
val currentState = page.state
|
||||
require(currentState is S)
|
||||
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package app.dapk.st.settings.state
|
||||
|
||||
import android.net.Uri
|
||||
import app.dapk.st.push.Registrar
|
||||
import app.dapk.st.settings.SettingItem
|
||||
import app.dapk.state.Action
|
||||
|
||||
internal sealed interface ScreenAction : Action {
|
||||
data class OnClick(val item: SettingItem) : ScreenAction
|
||||
object OpenImportRoom : ScreenAction
|
||||
}
|
||||
|
||||
internal sealed interface RootActions : Action {
|
||||
object FetchProviders : RootActions
|
||||
data class SelectPushProvider(val registrar: Registrar) : RootActions
|
||||
data class ImportKeysFromFile(val file: Uri, val passphrase: String) : RootActions
|
||||
data class SelectKeysFile(val file: Uri) : RootActions
|
||||
}
|
||||
|
||||
internal sealed interface ComponentLifecycle : Action {
|
||||
object Visible : ComponentLifecycle
|
||||
}
|
@ -0,0 +1,179 @@
|
||||
package app.dapk.st.settings.state
|
||||
|
||||
import android.content.ContentResolver
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.State
|
||||
import app.dapk.st.core.ThemeStore
|
||||
import app.dapk.st.core.page.*
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import app.dapk.st.domain.application.eventlog.LoggingStore
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.push.PushTokenRegistrars
|
||||
import app.dapk.st.settings.*
|
||||
import app.dapk.st.settings.SettingItem.Id.*
|
||||
import app.dapk.st.settings.SettingsEvent.*
|
||||
import app.dapk.state.Combined2
|
||||
import app.dapk.state.async
|
||||
import app.dapk.state.createReducer
|
||||
import app.dapk.state.multi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
|
||||
internal fun settingsReducer(
|
||||
chatEngine: ChatEngine,
|
||||
cacheCleaner: StoreCleaner,
|
||||
contentResolver: ContentResolver,
|
||||
uriFilenameResolver: UriFilenameResolver,
|
||||
settingsItemFactory: SettingsItemFactory,
|
||||
pushTokenRegistrars: PushTokenRegistrars,
|
||||
themeStore: ThemeStore,
|
||||
loggingStore: LoggingStore,
|
||||
messageOptionsStore: MessageOptionsStore,
|
||||
eventEmitter: suspend (SettingsEvent) -> Unit,
|
||||
jobBag: JobBag,
|
||||
) = createPageReducer(
|
||||
initialPage = SpiderPage<Page>(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())),
|
||||
factory = {
|
||||
createReducer(
|
||||
initialState = Unit,
|
||||
|
||||
async(ComponentLifecycle.Visible::class) {
|
||||
jobBag.replace("page", coroutineScope.launch {
|
||||
val root = Page.Root(Lce.Content(settingsItemFactory.root()))
|
||||
val rootPage = SpiderPage(Page.Routes.root, "Settings", null, root)
|
||||
dispatch(PageAction.GoTo(rootPage))
|
||||
})
|
||||
},
|
||||
|
||||
async(RootActions.FetchProviders::class) {
|
||||
withPageContext<Page.PushProviders> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(options = Lce.Loading())))
|
||||
}
|
||||
|
||||
val currentSelection = pushTokenRegistrars.currentSelection()
|
||||
val options = pushTokenRegistrars.options()
|
||||
withPageContext<Page.PushProviders> {
|
||||
pageDispatch(
|
||||
PageStateChange.UpdatePage(
|
||||
it.copy(
|
||||
selection = currentSelection,
|
||||
options = Lce.Content(options)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async(RootActions.SelectPushProvider::class) {
|
||||
pushTokenRegistrars.makeSelection(it.registrar)
|
||||
dispatch(RootActions.FetchProviders)
|
||||
},
|
||||
|
||||
async(RootActions.ImportKeysFromFile::class) { action ->
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = ImportResult.Update(0))))
|
||||
}
|
||||
|
||||
with(chatEngine) {
|
||||
runCatching { contentResolver.openInputStream(action.file)!! }
|
||||
.fold(
|
||||
onSuccess = { fileStream ->
|
||||
fileStream.importRoomKeys(action.passphrase)
|
||||
.onEach { progress ->
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = progress)))
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
},
|
||||
onFailure = {
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile))))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async(RootActions.SelectKeysFile::class) { action ->
|
||||
val namedFile = NamedUri(
|
||||
name = uriFilenameResolver.readFilenameFromUri(action.file),
|
||||
uri = action.file
|
||||
)
|
||||
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(selectedFile = namedFile)))
|
||||
}
|
||||
},
|
||||
|
||||
async(ScreenAction.OpenImportRoom::class) {
|
||||
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey())))
|
||||
},
|
||||
|
||||
multi(ScreenAction.OnClick::class) { action ->
|
||||
val item = action.item
|
||||
when (item.id) {
|
||||
SignOut -> sideEffect {
|
||||
cacheCleaner.cleanCache(removeCredentials = true)
|
||||
eventEmitter.invoke(SignedOut)
|
||||
}
|
||||
|
||||
AccessToken -> sideEffect {
|
||||
require(item is SettingItem.AccessToken)
|
||||
eventEmitter.invoke(CopyToClipboard("Token copied", item.accessToken))
|
||||
}
|
||||
|
||||
ClearCache -> sideEffect {
|
||||
cacheCleaner.cleanCache(removeCredentials = false)
|
||||
eventEmitter.invoke(Toast(message = "Cache deleted"))
|
||||
}
|
||||
|
||||
Encryption -> async {
|
||||
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security)))
|
||||
}
|
||||
|
||||
PrivacyPolicy -> sideEffect {
|
||||
eventEmitter.invoke(OpenUrl(PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
PushProvider -> async {
|
||||
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.pushProviders, "Push providers", Page.Routes.root, Page.PushProviders())))
|
||||
}
|
||||
|
||||
Ignored -> {
|
||||
nothing()
|
||||
}
|
||||
|
||||
ToggleDynamicTheme -> async {
|
||||
themeStore.storeMaterialYouEnabled(!themeStore.isMaterialYouEnabled())
|
||||
dispatch(ComponentLifecycle.Visible)
|
||||
eventEmitter.invoke(RecreateActivity)
|
||||
}
|
||||
|
||||
ToggleEnableLogs -> async {
|
||||
loggingStore.setEnabled(!loggingStore.isEnabled())
|
||||
dispatch(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
EventLog -> sideEffect {
|
||||
eventEmitter.invoke(OpenEventLog)
|
||||
}
|
||||
|
||||
ToggleSendReadReceipts -> async {
|
||||
messageOptionsStore.setReadReceiptsDisabled(!messageOptionsStore.isReadReceiptsDisabled())
|
||||
dispatch(ComponentLifecycle.Visible)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
internal typealias SettingsState = State<Combined2<PageContainer<Page>, Unit>, SettingsEvent>
|
@ -78,5 +78,6 @@ class FakePushRegistrars {
|
||||
val instance = mockk<PushTokenRegistrars>()
|
||||
|
||||
fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn()
|
||||
fun givenOptions() = coEvery { instance.options() }.delegateReturn()
|
||||
|
||||
}
|
@ -0,0 +1,281 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.core.page.PageContainer
|
||||
import app.dapk.st.core.page.PageStateChange
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.push.Registrar
|
||||
import app.dapk.st.settings.state.ComponentLifecycle
|
||||
import app.dapk.st.settings.state.RootActions
|
||||
import app.dapk.st.settings.state.ScreenAction
|
||||
import app.dapk.st.settings.state.settingsReducer
|
||||
import app.dapk.state.Combined2
|
||||
import fake.*
|
||||
import fixture.aRoomId
|
||||
import internalfake.FakeSettingsItemFactory
|
||||
import internalfake.FakeUriFilenameResolver
|
||||
import internalfixture.aImportRoomKeysPage
|
||||
import internalfixture.aPushProvidersPage
|
||||
import internalfixture.aSettingTextItem
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
import test.*
|
||||
|
||||
private const val APP_PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
private val A_LIST_OF_ROOT_ITEMS = listOf(aSettingTextItem())
|
||||
private val A_URI = FakeUri()
|
||||
private const val A_FILENAME = "a-filename.jpg"
|
||||
private val AN_INITIAL_IMPORT_ROOM_KEYS_PAGE = aImportRoomKeysPage()
|
||||
private val AN_INITIAL_PUSH_PROVIDERS_PAGE = aPushProvidersPage()
|
||||
private val A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION = aImportRoomKeysPage(
|
||||
state = Page.ImportRoomKey(selectedFile = NamedUri(A_FILENAME, A_URI.instance))
|
||||
)
|
||||
private val A_LIST_OF_ROOM_IDS = listOf(aRoomId())
|
||||
private val AN_IMPORT_SUCCESS = ImportResult.Success(A_LIST_OF_ROOM_IDS.toSet(), totalImportedKeysCount = 5)
|
||||
private val AN_IMPORT_FILE_ERROR = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)
|
||||
private val AN_INPUT_STREAM = FakeInputStream()
|
||||
private const val A_PASSPHRASE = "passphrase"
|
||||
private val AN_ERROR = RuntimeException()
|
||||
private val A_REGISTRAR = Registrar("a-registrar-id")
|
||||
private val A_PUSH_OPTIONS = listOf(Registrar("a-registrar-id"))
|
||||
|
||||
internal class SettingsReducerTest {
|
||||
|
||||
private val fakeStoreCleaner = FakeStoreCleaner()
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
|
||||
private val fakePushTokenRegistrars = FakePushRegistrars()
|
||||
private val fakeSettingsItemFactory = FakeSettingsItemFactory()
|
||||
private val fakeThemeStore = FakeThemeStore()
|
||||
private val fakeLoggingStore = FakeLoggingStore()
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
|
||||
private val runReducerTest = testReducer { fakeEventSource ->
|
||||
settingsReducer(
|
||||
fakeChatEngine,
|
||||
fakeStoreCleaner,
|
||||
fakeContentResolver.instance,
|
||||
fakeUriFilenameResolver.instance,
|
||||
fakeSettingsItemFactory.instance,
|
||||
fakePushTokenRegistrars.instance,
|
||||
fakeThemeStore.instance,
|
||||
fakeLoggingStore.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
fakeEventSource,
|
||||
fakeJobBag.instance,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is root with loading`() = runReducerTest {
|
||||
assertInitialState(
|
||||
pageState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given root content, when Visible, then goes to root page with content`() = runReducerTest {
|
||||
fakeSettingsItemFactory.givenRoot().returns(A_LIST_OF_ROOT_ITEMS)
|
||||
fakeJobBag.instance.expect { it.replace("page", any()) }
|
||||
|
||||
reduce(ComponentLifecycle.Visible)
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageAction.GoTo(
|
||||
SpiderPage(
|
||||
Page.Routes.root,
|
||||
"Settings",
|
||||
null,
|
||||
Page.Root(Lce.Content(A_LIST_OF_ROOT_ITEMS))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when SelectPushProvider, then selects provider and refreshes`() = runReducerTest {
|
||||
fakePushTokenRegistrars.instance.expect { it.makeSelection(A_REGISTRAR) }
|
||||
|
||||
reduce(RootActions.SelectPushProvider(A_REGISTRAR))
|
||||
|
||||
assertOnlyDispatches(RootActions.FetchProviders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when FetchProviders, then selects provider and refreshes`() = runReducerTest {
|
||||
setState(pageState(aPushProvidersPage()))
|
||||
fakePushTokenRegistrars.givenOptions().returns(A_PUSH_OPTIONS)
|
||||
fakePushTokenRegistrars.givenCurrentSelection().returns(A_REGISTRAR)
|
||||
|
||||
reduce(RootActions.FetchProviders)
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
aPushProvidersPage().state.copy(options = Lce.Loading())
|
||||
),
|
||||
PageStateChange.UpdatePage(
|
||||
aPushProvidersPage().state.copy(
|
||||
selection = A_REGISTRAR,
|
||||
options = Lce.Content(A_PUSH_OPTIONS)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when SelectKeysFile, then updates ImportRoomKey page with file`() = runReducerTest {
|
||||
setState(pageState(AN_INITIAL_IMPORT_ROOM_KEYS_PAGE))
|
||||
fakeUriFilenameResolver.givenFilename(A_URI.instance).returns(A_FILENAME)
|
||||
|
||||
reduce(RootActions.SelectKeysFile(A_URI.instance))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
AN_INITIAL_IMPORT_ROOM_KEYS_PAGE.state.copy(
|
||||
selectedFile = NamedUri(A_FILENAME, A_URI.instance)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click SignOut, then clears store and signs out`() = runReducerTest {
|
||||
fakeStoreCleaner.expectUnit { it.cleanCache(removeCredentials = true) }
|
||||
val aSignOutItem = aSettingTextItem(id = SettingItem.Id.SignOut)
|
||||
|
||||
reduce(ScreenAction.OnClick(aSignOutItem))
|
||||
|
||||
assertEvents(SettingsEvent.SignedOut)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click Encryption, then goes to Encryption page`() = runReducerTest {
|
||||
val anEncryptionItem = aSettingTextItem(id = SettingItem.Id.Encryption)
|
||||
|
||||
reduce(ScreenAction.OnClick(anEncryptionItem))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageAction.GoTo(
|
||||
SpiderPage(
|
||||
Page.Routes.encryption,
|
||||
"Encryption",
|
||||
Page.Routes.root,
|
||||
Page.Security
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click PrivacyPolicy, then opens privacy policy url`() = runReducerTest {
|
||||
val aPrivacyPolicyItem = aSettingTextItem(id = SettingItem.Id.PrivacyPolicy)
|
||||
|
||||
reduce(ScreenAction.OnClick(aPrivacyPolicyItem))
|
||||
|
||||
assertOnlyEvents(SettingsEvent.OpenUrl(APP_PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click PushProvider, then goes to PushProvider page`() = runReducerTest {
|
||||
val aPushProviderItem = aSettingTextItem(id = SettingItem.Id.PushProvider)
|
||||
|
||||
reduce(ScreenAction.OnClick(aPushProviderItem))
|
||||
|
||||
assertOnlyDispatches(PageAction.GoTo(aPushProvidersPage()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click Ignored, then does nothing`() = runReducerTest {
|
||||
val anIgnoredItem = aSettingTextItem(id = SettingItem.Id.Ignored)
|
||||
|
||||
reduce(ScreenAction.OnClick(anIgnoredItem))
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click ToggleDynamicTheme, then toggles flag, recreates activity and reloads`() = runReducerTest {
|
||||
val aToggleThemeItem = aSettingTextItem(id = SettingItem.Id.ToggleDynamicTheme)
|
||||
fakeThemeStore.givenMaterialYouIsEnabled().returns(true)
|
||||
fakeThemeStore.instance.expect { it.storeMaterialYouEnabled(false) }
|
||||
|
||||
reduce(ScreenAction.OnClick(aToggleThemeItem))
|
||||
|
||||
assertEvents(SettingsEvent.RecreateActivity)
|
||||
assertDispatches(ComponentLifecycle.Visible)
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click ToggleEnableLogs, then toggles flag and reloads`() = runReducerTest {
|
||||
val aToggleEnableLogsItem = aSettingTextItem(id = SettingItem.Id.ToggleEnableLogs)
|
||||
fakeLoggingStore.givenLoggingIsEnabled().returns(true)
|
||||
fakeLoggingStore.instance.expect { it.setEnabled(false) }
|
||||
|
||||
reduce(ScreenAction.OnClick(aToggleEnableLogsItem))
|
||||
|
||||
assertOnlyDispatches(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click EventLog, then opens event log`() = runReducerTest {
|
||||
val anEventLogItem = aSettingTextItem(id = SettingItem.Id.EventLog)
|
||||
|
||||
reduce(ScreenAction.OnClick(anEventLogItem))
|
||||
|
||||
assertOnlyEvents(SettingsEvent.OpenEventLog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click ToggleSendReadReceipts, then toggles flag and reloads`() = runReducerTest {
|
||||
val aToggleReadReceiptsItem = aSettingTextItem(id = SettingItem.Id.ToggleSendReadReceipts)
|
||||
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(true)
|
||||
fakeMessageOptionsStore.instance.expect { it.setReadReceiptsDisabled(false) }
|
||||
|
||||
reduce(ScreenAction.OnClick(aToggleReadReceiptsItem))
|
||||
|
||||
assertOnlyDispatches(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given success, when ImportKeysFromFile, then dispatches progress`() = runReducerTest {
|
||||
setState(pageState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance)
|
||||
fakeChatEngine.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS))
|
||||
|
||||
reduce(RootActions.ImportKeysFromFile(A_URI.instance, A_PASSPHRASE))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = ImportResult.Update(0L))
|
||||
),
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = AN_IMPORT_SUCCESS)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given error, when ImportKeysFromFile, then dispatches error`() = runReducerTest {
|
||||
setState(pageState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
fakeContentResolver.givenFile(A_URI.instance).throws(AN_ERROR)
|
||||
|
||||
reduce(RootActions.ImportKeysFromFile(A_URI.instance, A_PASSPHRASE))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = ImportResult.Update(0L))
|
||||
),
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = AN_IMPORT_FILE_ERROR)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun <P> pageState(page: SpiderPage<out P>) = Combined2(PageContainer(page), Unit)
|
@ -1,210 +0,0 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import ViewModelTest
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import fake.*
|
||||
import fixture.aRoomId
|
||||
import internalfake.FakeSettingsItemFactory
|
||||
import internalfake.FakeUriFilenameResolver
|
||||
import internalfixture.aImportRoomKeysPage
|
||||
import internalfixture.aSettingTextItem
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
|
||||
private const val APP_PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
private val A_LIST_OF_ROOT_ITEMS = listOf(aSettingTextItem())
|
||||
private val A_URI = FakeUri()
|
||||
private const val A_FILENAME = "a-filename.jpg"
|
||||
private val AN_INITIAL_IMPORT_ROOM_KEYS_PAGE = aImportRoomKeysPage()
|
||||
private val A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION = aImportRoomKeysPage(
|
||||
state = Page.ImportRoomKey(selectedFile = NamedUri(A_FILENAME, A_URI.instance))
|
||||
)
|
||||
private val A_LIST_OF_ROOM_IDS = listOf(aRoomId())
|
||||
private val AN_IMPORT_SUCCESS = ImportResult.Success(A_LIST_OF_ROOM_IDS.toSet(), totalImportedKeysCount = 5)
|
||||
private val AN_IMPORT_FILE_ERROR = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)
|
||||
private val AN_INPUT_STREAM = FakeInputStream()
|
||||
private const val A_PASSPHRASE = "passphrase"
|
||||
private val AN_ERROR = RuntimeException()
|
||||
|
||||
internal class SettingsViewModelTest {
|
||||
|
||||
private val runViewModelTest = ViewModelTest()
|
||||
|
||||
private val fakeStoreCleaner = FakeStoreCleaner()
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
|
||||
private val fakePushTokenRegistrars = FakePushRegistrars()
|
||||
private val fakeSettingsItemFactory = FakeSettingsItemFactory()
|
||||
private val fakeThemeStore = FakeThemeStore()
|
||||
private val fakeLoggingStore = FakeLoggingStore()
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
|
||||
private val viewModel = SettingsViewModel(
|
||||
fakeChatEngine,
|
||||
fakeStoreCleaner,
|
||||
fakeContentResolver.instance,
|
||||
fakeUriFilenameResolver.instance,
|
||||
fakeSettingsItemFactory.instance,
|
||||
fakePushTokenRegistrars.instance,
|
||||
fakeThemeStore.instance,
|
||||
fakeLoggingStore.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
runViewModelTest.testMutableStateFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when creating view model then initial state is loading Root`() = runViewModelTest {
|
||||
viewModel.test()
|
||||
|
||||
assertInitialState(
|
||||
SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when starting, then emits root page with content`() = runViewModelTest {
|
||||
fakeSettingsItemFactory.givenRoot().returns(A_LIST_OF_ROOT_ITEMS)
|
||||
|
||||
viewModel.test().start()
|
||||
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
SpiderPage(
|
||||
Page.Routes.root,
|
||||
"Settings",
|
||||
null,
|
||||
Page.Root(Lce.Content(A_LIST_OF_ROOT_ITEMS))
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when sign out clicked, then clears store`() = runViewModelTest {
|
||||
fakeStoreCleaner.expectUnit { it.cleanCache(removeCredentials = true) }
|
||||
val aSignOutItem = aSettingTextItem(id = SettingItem.Id.SignOut)
|
||||
|
||||
viewModel.test().onClick(aSignOutItem)
|
||||
|
||||
assertNoStates<SettingsScreenState>()
|
||||
assertEvents(SettingsEvent.SignedOut)
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when event log clicked, then opens event log`() = runViewModelTest {
|
||||
val anEventLogItem = aSettingTextItem(id = SettingItem.Id.EventLog)
|
||||
|
||||
viewModel.test().onClick(anEventLogItem)
|
||||
|
||||
assertNoStates<SettingsScreenState>()
|
||||
assertEvents(SettingsEvent.OpenEventLog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when encryption clicked, then emits encryption page`() = runViewModelTest {
|
||||
val anEncryptionItem = aSettingTextItem(id = SettingItem.Id.Encryption)
|
||||
|
||||
viewModel.test().onClick(anEncryptionItem)
|
||||
|
||||
assertNoEvents<SettingsEvent>()
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
SpiderPage(
|
||||
route = Page.Routes.encryption,
|
||||
label = "Encryption",
|
||||
parent = Page.Routes.root,
|
||||
state = Page.Security
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when privacy policy clicked, then opens privacy policy url`() = runViewModelTest {
|
||||
val aPrivacyPolicyItem = aSettingTextItem(id = SettingItem.Id.PrivacyPolicy)
|
||||
|
||||
viewModel.test().onClick(aPrivacyPolicyItem)
|
||||
|
||||
assertNoStates<SettingsScreenState>()
|
||||
assertEvents(SettingsEvent.OpenUrl(APP_PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when going to import room, then emits import room keys page`() = runViewModelTest {
|
||||
viewModel.test().goToImportRoom()
|
||||
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
SpiderPage(
|
||||
route = Page.Routes.importRoomKeys,
|
||||
label = "Import room keys",
|
||||
parent = Page.Routes.encryption,
|
||||
state = Page.ImportRoomKey()
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given on import room keys page, when selecting file, then emits selection`() = runViewModelTest {
|
||||
fakeUriFilenameResolver.givenFilename(A_URI.instance).returns(A_FILENAME)
|
||||
|
||||
viewModel.test(initialState = SettingsScreenState(AN_INITIAL_IMPORT_ROOM_KEYS_PAGE)).fileSelected(A_URI.instance)
|
||||
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
AN_INITIAL_IMPORT_ROOM_KEYS_PAGE.copy(
|
||||
state = Page.ImportRoomKey(
|
||||
selectedFile = NamedUri(A_FILENAME, A_URI.instance)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given success when importing room keys, then emits progress`() = runViewModelTest {
|
||||
fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance)
|
||||
fakeChatEngine.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS))
|
||||
|
||||
viewModel
|
||||
.test(initialState = SettingsScreenState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
.importFromFileKeys(A_URI.instance, A_PASSPHRASE)
|
||||
|
||||
assertStates<SettingsScreenState>(
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0L)) }) },
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = AN_IMPORT_SUCCESS) }) },
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given error when importing room keys, then emits error`() = runViewModelTest {
|
||||
fakeContentResolver.givenFile(A_URI.instance).throws(AN_ERROR)
|
||||
|
||||
viewModel
|
||||
.test(initialState = SettingsScreenState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
.importFromFileKeys(A_URI.instance, A_PASSPHRASE)
|
||||
|
||||
assertStates<SettingsScreenState>(
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0L)) }) },
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = AN_IMPORT_FILE_ERROR) }) },
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified S : Page> SpiderPage<out Page>.updateState(crossinline block: S.() -> S): SpiderPage<Page> {
|
||||
require(this.state is S)
|
||||
return (this as SpiderPage<S>).copy(state = block(this.state)) as SpiderPage<Page>
|
||||
}
|
@ -11,3 +11,12 @@ internal fun aImportRoomKeysPage(
|
||||
parent = Page.Routes.encryption,
|
||||
state = state
|
||||
)
|
||||
|
||||
internal fun aPushProvidersPage(
|
||||
state: Page.PushProviders = Page.PushProviders()
|
||||
) = SpiderPage(
|
||||
route = Page.Routes.pushProviders,
|
||||
label = "Push providers",
|
||||
parent = Page.Routes.root,
|
||||
state = state
|
||||
)
|
||||
|
@ -0,0 +1,53 @@
|
||||
package app.dapk.st.engine
|
||||
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.RoomMember
|
||||
import app.dapk.st.matrix.common.UserId
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import app.dapk.st.matrix.room.RoomService
|
||||
|
||||
internal typealias DirectoryMergeWithLocalEchosUseCase = suspend (OverviewState, UserId, Map<RoomId, List<MessageService.LocalEcho>>) -> OverviewState
|
||||
|
||||
internal class DirectoryMergeWithLocalEchosUseCaseImpl(
|
||||
private val roomService: RoomService,
|
||||
) : DirectoryMergeWithLocalEchosUseCase {
|
||||
|
||||
override suspend fun invoke(overview: OverviewState, selfId: UserId, echos: Map<RoomId, List<MessageService.LocalEcho>>): OverviewState {
|
||||
return when {
|
||||
echos.isEmpty() -> overview
|
||||
else -> overview.map {
|
||||
when (val roomEchos = echos[it.roomId]) {
|
||||
null -> it
|
||||
else -> it.mergeWithLocalEchos(
|
||||
member = roomService.findMember(it.roomId, selfId) ?: RoomMember(
|
||||
selfId,
|
||||
null,
|
||||
avatarUrl = null,
|
||||
),
|
||||
echos = roomEchos,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomOverview.mergeWithLocalEchos(member: RoomMember, echos: List<MessageService.LocalEcho>): RoomOverview {
|
||||
val latestEcho = echos.maxByOrNull { it.timestampUtc }
|
||||
return if (latestEcho != null && latestEcho.timestampUtc > (this.lastMessage?.utcTimestamp ?: 0)) {
|
||||
this.copy(
|
||||
lastMessage = RoomOverview.LastMessage(
|
||||
content = when (val message = latestEcho.message) {
|
||||
is MessageService.Message.TextMessage -> message.content.body.asString()
|
||||
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
|
||||
},
|
||||
utcTimestamp = latestEcho.timestampUtc,
|
||||
author = member,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,83 +1,43 @@
|
||||
package app.dapk.st.engine
|
||||
|
||||
import app.dapk.st.matrix.common.*
|
||||
import app.dapk.st.core.extensions.combine
|
||||
import app.dapk.st.matrix.common.CredentialsStore
|
||||
import app.dapk.st.matrix.message.MessageService
|
||||
import app.dapk.st.matrix.room.RoomService
|
||||
import app.dapk.st.matrix.sync.RoomStore
|
||||
import app.dapk.st.matrix.sync.SyncService
|
||||
import app.dapk.st.matrix.sync.SyncService.SyncEvent.Typing
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapConcat
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
internal class DirectoryUseCase(
|
||||
private val syncService: SyncService,
|
||||
private val messageService: MessageService,
|
||||
private val roomService: RoomService,
|
||||
private val credentialsStore: CredentialsStore,
|
||||
private val roomStore: RoomStore,
|
||||
private val mergeLocalEchosUseCase: DirectoryMergeWithLocalEchosUseCase,
|
||||
) {
|
||||
|
||||
fun state(): Flow<DirectoryState> {
|
||||
return flow { emit(credentialsStore.credentials()!!.userId) }.flatMapMerge { userId ->
|
||||
return flow { emit(credentialsStore.credentials()!!.userId) }.flatMapConcat { userId ->
|
||||
combine(
|
||||
overviewDatasource(),
|
||||
syncService.startSyncing(),
|
||||
syncService.overview().map { it.map { it.engine() } },
|
||||
messageService.localEchos(),
|
||||
roomStore.observeUnreadCountById(),
|
||||
syncService.events()
|
||||
) { overviewState, localEchos, unread, events ->
|
||||
overviewState.mergeWithLocalEchos(localEchos, userId).map { roomOverview ->
|
||||
syncService.events(),
|
||||
roomStore.observeMuted(),
|
||||
) { _, overviewState, localEchos, unread, events, muted ->
|
||||
mergeLocalEchosUseCase.invoke(overviewState, userId, localEchos).map { roomOverview ->
|
||||
DirectoryItem(
|
||||
overview = roomOverview,
|
||||
unreadCount = UnreadCount(unread[roomOverview.roomId] ?: 0),
|
||||
typing = events.filterIsInstance<Typing>().firstOrNull { it.roomId == roomOverview.roomId }?.engine()
|
||||
typing = events.filterIsInstance<Typing>().firstOrNull { it.roomId == roomOverview.roomId }?.engine(),
|
||||
isMuted = muted.contains(roomOverview.roomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun overviewDatasource() = combine(
|
||||
syncService.startSyncing(),
|
||||
syncService.overview().map { it.map { it.engine() } }
|
||||
) { _, overview -> overview }.filterNotNull()
|
||||
|
||||
private suspend fun OverviewState.mergeWithLocalEchos(localEchos: Map<RoomId, List<MessageService.LocalEcho>>, userId: UserId): OverviewState {
|
||||
return when {
|
||||
localEchos.isEmpty() -> this
|
||||
else -> this.map {
|
||||
when (val roomEchos = localEchos[it.roomId]) {
|
||||
null -> it
|
||||
else -> it.mergeWithLocalEchos(
|
||||
member = roomService.findMember(it.roomId, userId) ?: RoomMember(
|
||||
userId,
|
||||
null,
|
||||
avatarUrl = null,
|
||||
),
|
||||
echos = roomEchos,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomOverview.mergeWithLocalEchos(member: RoomMember, echos: List<MessageService.LocalEcho>): RoomOverview {
|
||||
val latestEcho = echos.maxByOrNull { it.timestampUtc }
|
||||
return if (latestEcho != null && latestEcho.timestampUtc > (this.lastMessage?.utcTimestamp ?: 0)) {
|
||||
this.copy(
|
||||
lastMessage = RoomOverview.LastMessage(
|
||||
content = when (val message = latestEcho.message) {
|
||||
is MessageService.Message.TextMessage -> message.content.body.asString()
|
||||
is MessageService.Message.ImageMessage -> "\uD83D\uDCF7"
|
||||
},
|
||||
utcTimestamp = latestEcho.timestampUtc,
|
||||
author = member,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user