Merge pull request #251 from ouchadam/release-candidate

[Auto] Release Candidate
This commit is contained in:
Adam Brown 2022-11-06 19:01:05 +00:00 committed by GitHub
commit a242f8238d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
152 changed files with 5040 additions and 2120 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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(

View File

@ -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)

View 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()
}
}

View File

@ -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())

View File

@ -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,
)
}
}

View File

@ -0,0 +1,9 @@
package fake
import app.dapk.st.core.JobBag
import io.mockk.mockk
class FakeJobBag {
val instance = mockk<JobBag>()
}

View File

@ -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)
}

View File

@ -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("@")}")
}
}

View File

@ -61,4 +61,4 @@ data class SpiderPage<T>(
)
@JvmInline
value class Route<S>(val value: String)
value class Route<out S>(val value: String)

View File

@ -5,4 +5,5 @@ dependencies {
implementation project(":features:navigator")
implementation project(":design-library")
api project(":domains:android:core")
api project(":domains:state")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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) } }
}

View File

@ -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
}

View File

@ -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"))
}

View File

@ -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),
)
}

View File

@ -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?)

View File

@ -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)
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}
}
}

View File

@ -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,
)

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()
}
}

View File

@ -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()

View File

@ -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()
}

View File

@ -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>()
}

View File

@ -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()
}

View File

@ -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>()
}

View File

@ -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
}
}

View File

@ -25,4 +25,3 @@ abstract class DapkViewModel<S, VE>(initialState: S, factory: MutableStateFactor
state = reducer(state)
}
}

View 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
}

View 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

View File

@ -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)
}
}

View 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())
}

View File

@ -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)

View File

@ -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)

View File

@ -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() }
}

View File

@ -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)

View File

@ -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;

View File

@ -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 = ?;

View File

@ -16,7 +16,6 @@ FROM dbRoomMember
WHERE room_id = ?
LIMIT ?;
insert:
INSERT OR REPLACE INTO dbRoomMember(user_id, room_id, blob)
VALUES (?, ?, ?);

View File

@ -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"))

View File

@ -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

View File

@ -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) }
}
}
}

View File

@ -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)
}
}

View File

@ -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>()

View File

@ -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
}

View File

@ -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)
}
)
}

View File

@ -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
}

View File

@ -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>()
}

View File

@ -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>()
}

View File

@ -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

View File

@ -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(),

View File

@ -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() }

View File

@ -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()
}
}
}

View File

@ -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) }

View File

@ -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"))

View File

@ -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)
}
}
}

View File

@ -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())

View File

@ -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,

View File

@ -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
}

View File

@ -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()
}
}

View File

@ -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(

View File

@ -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()
}

View File

@ -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(),
)
}
}

View File

@ -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 {

View File

@ -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

View File

@ -0,0 +1,8 @@
package app.dapk.st.messenger.gallery
import android.net.Uri
class MediaUriAvoidance(
val uriAppender: (Uri, Long) -> Uri,
val externalContentUri: Uri,
)

View File

@ -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
}

View File

@ -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)
},
)
}
)

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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(

View File

@ -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()
}

View File

@ -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>()
}

View File

@ -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
}

View File

@ -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",
)

View File

@ -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)

View File

@ -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))
}
}
}

View File

@ -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"
}
}

View File

@ -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()
}

View File

@ -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() {

View File

@ -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"))

View File

@ -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()

View File

@ -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())
}

View File

@ -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 {

View File

@ -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))) }
}
}

View File

@ -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
}

View File

@ -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>

View File

@ -78,5 +78,6 @@ class FakePushRegistrars {
val instance = mockk<PushTokenRegistrars>()
fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn()
fun givenOptions() = coEvery { instance.options() }.delegateReturn()
}

View File

@ -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)

View File

@ -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>
}

View File

@ -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
)

View File

@ -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
}
}
}

View File

@ -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