mirror of
https://github.com/ouchadam/small-talk.git
synced 2025-03-27 01:10:16 +01:00
Merge pull request #234 from ouchadam/tech/redux-pages
tech/redux pattern
This commit is contained in:
commit
40534bc581
@ -1,6 +1,7 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class JobBag {
|
||||
|
||||
@ -11,8 +12,17 @@ class JobBag {
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
9
core/src/testFixtures/kotlin/fake/FakeJobBag.kt
Normal file
9
core/src/testFixtures/kotlin/fake/FakeJobBag.kt
Normal file
@ -0,0 +1,9 @@
|
||||
package fake
|
||||
|
||||
import app.dapk.st.core.JobBag
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeJobBag {
|
||||
val instance = mockk<JobBag>()
|
||||
}
|
||||
|
@ -61,4 +61,4 @@ data class SpiderPage<T>(
|
||||
)
|
||||
|
||||
@JvmInline
|
||||
value class Route<S>(val value: String)
|
||||
value class Route<out S>(val value: String)
|
@ -0,0 +1,93 @@
|
||||
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))
|
||||
}
|
||||
},
|
||||
|
||||
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) } }
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ fun interface Reducer<S> {
|
||||
|
||||
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
|
||||
override val coroutineScope = coroutineScope
|
||||
override suspend fun dispatch(action: Action) = store.dispatch(action)
|
||||
override fun dispatch(action: Action) = store.dispatch(action)
|
||||
override fun getState(): S = store.getState()
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ interface Store<S> {
|
||||
|
||||
interface ReducerScope<S> {
|
||||
val coroutineScope: CoroutineScope
|
||||
suspend fun dispatch(action: Action)
|
||||
fun dispatch(action: Action)
|
||||
fun getState(): S
|
||||
}
|
||||
|
||||
@ -64,6 +64,45 @@ sealed interface 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>,
|
||||
@ -132,9 +171,10 @@ fun <A : Action, S> async(klass: KClass<A>, block: suspend ReducerScope<S>.(A) -
|
||||
|
||||
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: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = sideEffect(klass, block)
|
||||
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 {
|
||||
@ -145,7 +185,8 @@ fun <A : Action, S> multi(klass: KClass<A>, block: Multi<A, S>.(A) -> (ReducerSc
|
||||
}
|
||||
|
||||
interface Multi<A : Action, S> {
|
||||
fun sideEffect(block: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<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>
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ class ReducerTestScope<S, E>(
|
||||
private val actionCaptures = mutableListOf<Action>()
|
||||
private val reducerScope = object : ReducerScope<S> {
|
||||
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
|
||||
override suspend fun dispatch(action: Action) {
|
||||
override fun dispatch(action: Action) {
|
||||
actionCaptures.add(action)
|
||||
}
|
||||
|
||||
@ -123,4 +123,20 @@ class ReducerTestScope<S, E>(
|
||||
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())
|
||||
}
|
@ -30,7 +30,7 @@ internal fun directoryReducer(
|
||||
}.launchIn(coroutineScope))
|
||||
}
|
||||
|
||||
ComponentLifecycle.OnGone -> sideEffect { _, _ -> jobBag.cancel(KEY_SYNCING_JOB) }
|
||||
ComponentLifecycle.OnGone -> sideEffect { jobBag.cancel(KEY_SYNCING_JOB) }
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
package app.dapk.st.directory
|
||||
|
||||
import app.dapk.st.core.JobBag
|
||||
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
|
||||
@ -90,8 +90,3 @@ class DirectoryReducerTest {
|
||||
internal class FakeShortcutHandler {
|
||||
val instance = mockk<ShortcutHandler>()
|
||||
}
|
||||
|
||||
class FakeJobBag {
|
||||
val instance = mockk<JobBag>()
|
||||
}
|
||||
|
||||
|
@ -23,9 +23,9 @@ import kotlinx.parcelize.Parcelize
|
||||
class ImageGalleryActivity : DapkActivity() {
|
||||
|
||||
private val module by unsafeLazy { module<ImageGalleryModule>() }
|
||||
private val viewModel by viewModel {
|
||||
private val imageGalleryState by state {
|
||||
val payload = intent.getParcelableExtra("key") as? ImageGalleryActivityPayload
|
||||
module.imageGalleryViewModel(payload!!.roomName)
|
||||
module.imageGalleryState(payload!!.roomName)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@ -42,7 +42,7 @@ class ImageGalleryActivity : DapkActivity() {
|
||||
setContent {
|
||||
Surface {
|
||||
PermissionGuard(permissionState) {
|
||||
ImageGalleryScreen(viewModel, onTopLevelBack = { finish() }) { media ->
|
||||
ImageGalleryScreen(imageGalleryState, onTopLevelBack = { finish() }) { media ->
|
||||
setResult(RESULT_OK, Intent().setData(media.uri))
|
||||
finish()
|
||||
}
|
||||
|
@ -1,18 +1,22 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import android.content.ContentResolver
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.core.*
|
||||
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 {
|
||||
imageGalleryReducer(
|
||||
roomName = roomName,
|
||||
FetchMediaFoldersUseCase(contentResolver, dispatchers),
|
||||
FetchMediaUseCase(contentResolver, dispatchers),
|
||||
JobBag(),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
@ -21,38 +21,45 @@ import androidx.compose.ui.unit.sp
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.LifecycleEffect
|
||||
import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.design.components.GenericError
|
||||
import app.dapk.st.design.components.Spider
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.messenger.gallery.state.ImageGalleryActions
|
||||
import app.dapk.st.messenger.gallery.state.ImageGalleryPage
|
||||
import app.dapk.st.messenger.gallery.state.ImageGalleryState
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryScreen(viewModel: ImageGalleryViewModel, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
|
||||
fun ImageGalleryScreen(state: ImageGalleryState, onTopLevelBack: () -> Unit, onImageSelected: (Media) -> Unit) {
|
||||
LifecycleEffect(onStart = {
|
||||
viewModel.start()
|
||||
state.dispatch(ImageGalleryActions.Visible)
|
||||
})
|
||||
|
||||
val onNavigate: (SpiderPage<out ImageGalleryPage>?) -> Unit = {
|
||||
when (it) {
|
||||
null -> onTopLevelBack()
|
||||
else -> viewModel.goTo(it)
|
||||
else -> state.dispatch(PageAction.GoTo(it))
|
||||
}
|
||||
}
|
||||
|
||||
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
|
||||
Spider(currentPage = state.current.state1.page, onNavigate = onNavigate) {
|
||||
item(ImageGalleryPage.Routes.folders) {
|
||||
ImageGalleryFolders(it, onClick = { viewModel.selectFolder(it) }, onRetry = { viewModel.start() })
|
||||
ImageGalleryFolders(
|
||||
it,
|
||||
onClick = { state.dispatch(ImageGalleryActions.SelectFolder(it)) },
|
||||
onRetry = { state.dispatch(ImageGalleryActions.Visible) }
|
||||
)
|
||||
}
|
||||
item(ImageGalleryPage.Routes.files) {
|
||||
ImageGalleryMedia(it, onImageSelected, onRetry = { viewModel.selectFolder(it.folder) })
|
||||
ImageGalleryMedia(it, onImageSelected, onRetry = { state.dispatch(ImageGalleryActions.SelectFolder(it.folder)) })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit, onRetry: () -> Unit) {
|
||||
private fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Unit, onRetry: () -> Unit) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
|
||||
val gradient = Brush.verticalGradient(
|
||||
@ -108,7 +115,7 @@ fun ImageGalleryFolders(state: ImageGalleryPage.Folders, onClick: (Folder) -> Un
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit, onRetry: () -> Unit) {
|
||||
private fun ImageGalleryMedia(state: ImageGalleryPage.Files, onFileSelected: (Media) -> Unit, onRetry: () -> Unit) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp
|
||||
|
||||
Column {
|
||||
|
@ -1,89 +0,0 @@
|
||||
package app.dapk.st.messenger.gallery
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.Route
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ImageGalleryViewModel(
|
||||
private val foldersUseCase: FetchMediaFoldersUseCase,
|
||||
private val fetchMediaUseCase: FetchMediaUseCase,
|
||||
roomName: String,
|
||||
) : DapkViewModel<ImageGalleryState, ImageGalleryEvent>(
|
||||
initialState = ImageGalleryState(
|
||||
page = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.folders,
|
||||
label = "Send to $roomName",
|
||||
parent = null,
|
||||
state = ImageGalleryPage.Folders(Lce.Loading())
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
||||
private var currentPageJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
currentPageJob?.cancel()
|
||||
currentPageJob = viewModelScope.launch {
|
||||
val folders = foldersUseCase.fetchFolders()
|
||||
updatePageState<ImageGalleryPage.Folders> { copy(content = Lce.Content(folders)) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun goTo(page: SpiderPage<out ImageGalleryPage>) {
|
||||
currentPageJob?.cancel()
|
||||
updateState { copy(page = page) }
|
||||
}
|
||||
|
||||
fun selectFolder(folder: Folder) {
|
||||
currentPageJob?.cancel()
|
||||
|
||||
updateState {
|
||||
copy(
|
||||
page = SpiderPage(
|
||||
route = ImageGalleryPage.Routes.files,
|
||||
label = page.label,
|
||||
parent = ImageGalleryPage.Routes.folders,
|
||||
state = ImageGalleryPage.Files(Lce.Loading(), folder)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
currentPageJob = viewModelScope.launch {
|
||||
val media = fetchMediaUseCase.getMediaInBucket(folder.bucketId)
|
||||
updatePageState<ImageGalleryPage.Files> {
|
||||
copy(content = Lce.Content(media))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified S : ImageGalleryPage> updatePageState(crossinline block: S.() -> S) {
|
||||
val page = state.page
|
||||
val currentState = page.state
|
||||
require(currentState is S)
|
||||
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class ImageGalleryState(
|
||||
val page: SpiderPage<out ImageGalleryPage>,
|
||||
)
|
||||
|
||||
|
||||
sealed interface ImageGalleryPage {
|
||||
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
|
||||
data class Files(val content: Lce<List<Media>>, val folder: Folder) : ImageGalleryPage
|
||||
|
||||
object Routes {
|
||||
val folders = Route<Folders>("Folders")
|
||||
val files = Route<Files>("Files")
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface ImageGalleryEvent
|
@ -0,0 +1,9 @@
|
||||
package app.dapk.st.messenger.gallery.state
|
||||
|
||||
import app.dapk.st.messenger.gallery.Folder
|
||||
import app.dapk.state.Action
|
||||
|
||||
sealed interface ImageGalleryActions : Action {
|
||||
object Visible : ImageGalleryActions
|
||||
data class SelectFolder(val folder: Folder) : ImageGalleryActions
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
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::class)
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
@ -0,0 +1,21 @@
|
||||
package app.dapk.st.messenger.gallery.state
|
||||
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.State
|
||||
import app.dapk.st.design.components.Route
|
||||
import app.dapk.st.messenger.gallery.Folder
|
||||
import app.dapk.st.messenger.gallery.Media
|
||||
import app.dapk.st.core.page.PageContainer
|
||||
import app.dapk.state.Combined2
|
||||
|
||||
typealias ImageGalleryState = State<Combined2<PageContainer<ImageGalleryPage>, Unit>, Unit>
|
||||
|
||||
sealed interface ImageGalleryPage {
|
||||
data class Folders(val content: Lce<List<Folder>>) : ImageGalleryPage
|
||||
data class Files(val content: Lce<List<Media>>, val folder: Folder) : ImageGalleryPage
|
||||
|
||||
object Routes {
|
||||
val folders = Route<Folders>("Folders")
|
||||
val files = Route<Files>("Files")
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ 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
|
||||
@ -271,7 +272,6 @@ class MessengerReducerTest {
|
||||
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)) }
|
||||
@ -352,10 +352,6 @@ class FakeCopyToClipboard {
|
||||
val instance = mockk<CopyToClipboard>()
|
||||
}
|
||||
|
||||
class FakeJobBag {
|
||||
val instance = mockk<JobBag>()
|
||||
}
|
||||
|
||||
class FakeDeviceMeta {
|
||||
val instance = mockk<DeviceMeta>()
|
||||
|
||||
|
@ -0,0 +1,54 @@
|
||||
package app.dapk.st.messenger.gallery.state
|
||||
|
||||
import app.dapk.st.core.Lce
|
||||
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.core.page.PageContainer
|
||||
import app.dapk.state.Combined2
|
||||
import fake.FakeJobBag
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import test.testReducer
|
||||
|
||||
private const val A_ROOM_NAME = "a room name"
|
||||
|
||||
class ImageGalleryReducerTest {
|
||||
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
|
||||
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(
|
||||
Combined2(
|
||||
state1 = PageContainer(
|
||||
SpiderPage(
|
||||
route = ImageGalleryPage.Routes.folders,
|
||||
label = "Send to $A_ROOM_NAME",
|
||||
parent = null,
|
||||
state = ImageGalleryPage.Folders(Lce.Loading())
|
||||
)
|
||||
),
|
||||
state2 = Unit
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class FakeFetchMediaFoldersUseCase {
|
||||
val instance = mockk<FetchMediaFoldersUseCase>()
|
||||
}
|
||||
|
||||
class FakeFetchMediaUseCase {
|
||||
val instance = mockk<FetchMediaUseCase>()
|
||||
}
|
@ -15,6 +15,7 @@ dependencies {
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:store"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:state"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
|
||||
androidImportFixturesWorkaround(project, project(":chat-engine"))
|
||||
|
@ -4,20 +4,17 @@ import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.dapk.st.core.DapkActivity
|
||||
import app.dapk.st.core.module
|
||||
import app.dapk.st.core.resetModules
|
||||
import app.dapk.st.core.viewModel
|
||||
import app.dapk.st.core.*
|
||||
|
||||
class SettingsActivity : DapkActivity() {
|
||||
|
||||
private val settingsViewModel by viewModel { module<SettingsModule>().settingsViewModel() }
|
||||
private val settingsState by state { module<SettingsModule>().settingsState() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
SettingsScreen(settingsViewModel, onSignOut = {
|
||||
SettingsScreen(settingsState, onSignOut = {
|
||||
resetModules()
|
||||
navigator.navigate.toHome()
|
||||
finish()
|
||||
|
@ -8,6 +8,8 @@ import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.push.PushModule
|
||||
import app.dapk.st.settings.eventlogger.EventLoggerViewModel
|
||||
import app.dapk.st.settings.state.SettingsState
|
||||
import app.dapk.st.settings.state.settingsReducer
|
||||
|
||||
class SettingsModule(
|
||||
private val chatEngine: ChatEngine,
|
||||
@ -22,20 +24,25 @@ class SettingsModule(
|
||||
private val messageOptionsStore: MessageOptionsStore,
|
||||
) : ProvidableModule {
|
||||
|
||||
internal fun settingsViewModel(): SettingsViewModel {
|
||||
return SettingsViewModel(
|
||||
chatEngine,
|
||||
storeModule.cacheCleaner(),
|
||||
contentResolver,
|
||||
UriFilenameResolver(contentResolver, coroutineDispatchers),
|
||||
SettingsItemFactory(buildMeta, deviceMeta, pushModule.pushTokenRegistrars(), themeStore, loggingStore, messageOptionsStore),
|
||||
pushModule.pushTokenRegistrars(),
|
||||
themeStore,
|
||||
loggingStore,
|
||||
messageOptionsStore,
|
||||
)
|
||||
internal fun settingsState(): SettingsState {
|
||||
return createStateViewModel {
|
||||
settingsReducer(
|
||||
chatEngine,
|
||||
storeModule.cacheCleaner(),
|
||||
contentResolver,
|
||||
UriFilenameResolver(contentResolver, coroutineDispatchers),
|
||||
SettingsItemFactory(buildMeta, deviceMeta, pushModule.pushTokenRegistrars(), themeStore, loggingStore, messageOptionsStore),
|
||||
pushModule.pushTokenRegistrars(),
|
||||
themeStore,
|
||||
loggingStore,
|
||||
messageOptionsStore,
|
||||
it,
|
||||
JobBag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal fun eventLogViewModel(): EventLoggerViewModel {
|
||||
return EventLoggerViewModel(storeModule.eventLogStore())
|
||||
}
|
||||
|
@ -41,35 +41,44 @@ import app.dapk.st.core.components.CenteredLoading
|
||||
import app.dapk.st.core.components.Header
|
||||
import app.dapk.st.core.extensions.takeAs
|
||||
import app.dapk.st.core.getActivity
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.design.components.*
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.navigator.Navigator
|
||||
import app.dapk.st.settings.SettingsEvent.*
|
||||
import app.dapk.st.settings.eventlogger.EventLogActivity
|
||||
import app.dapk.st.settings.state.ComponentLifecycle
|
||||
import app.dapk.st.settings.state.RootActions
|
||||
import app.dapk.st.settings.state.ScreenAction
|
||||
import app.dapk.st.settings.state.SettingsState
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, navigator: Navigator) {
|
||||
viewModel.ObserveEvents(onSignOut)
|
||||
internal fun SettingsScreen(settingsState: SettingsState, onSignOut: () -> Unit, navigator: Navigator) {
|
||||
settingsState.ObserveEvents(onSignOut)
|
||||
LaunchedEffect(true) {
|
||||
viewModel.start()
|
||||
settingsState.dispatch(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
val onNavigate: (SpiderPage<out Page>?) -> Unit = {
|
||||
when (it) {
|
||||
null -> navigator.navigate.upToHome()
|
||||
else -> viewModel.goTo(it)
|
||||
else -> settingsState.dispatch(PageAction.GoTo(it))
|
||||
}
|
||||
}
|
||||
Spider(currentPage = viewModel.state.page, onNavigate = onNavigate) {
|
||||
Spider(currentPage = settingsState.current.state1.page, onNavigate = onNavigate) {
|
||||
item(Page.Routes.root) {
|
||||
RootSettings(it, onClick = { viewModel.onClick(it) }, onRetry = { viewModel.start() })
|
||||
RootSettings(
|
||||
it,
|
||||
onClick = { settingsState.dispatch(ScreenAction.OnClick(it)) },
|
||||
onRetry = { settingsState.dispatch(ComponentLifecycle.Visible) }
|
||||
)
|
||||
}
|
||||
item(Page.Routes.encryption) {
|
||||
Encryption(viewModel, it)
|
||||
Encryption(settingsState, it)
|
||||
}
|
||||
item(Page.Routes.pushProviders) {
|
||||
PushProviders(viewModel, it)
|
||||
PushProviders(settingsState, it)
|
||||
}
|
||||
item(Page.Routes.importRoomKeys) {
|
||||
when (val result = it.importProgress) {
|
||||
@ -83,7 +92,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
|
||||
) {
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
|
||||
it?.let {
|
||||
viewModel.fileSelected(it)
|
||||
settingsState.dispatch(RootActions.SelectKeysFile(it))
|
||||
}
|
||||
}
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
@ -100,7 +109,7 @@ internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit,
|
||||
var passwordVisibility by rememberSaveable { mutableStateOf(false) }
|
||||
val startImportAction = {
|
||||
keyboardController?.hide()
|
||||
viewModel.importFromFileKeys(it.selectedFile.uri, passphrase)
|
||||
settingsState.dispatch(RootActions.ImportKeysFromFile(it.selectedFile.uri, passphrase))
|
||||
}
|
||||
|
||||
TextField(
|
||||
@ -235,40 +244,40 @@ private fun RootSettings(page: Page.Root, onClick: (SettingItem) -> Unit, onRetr
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Encryption(viewModel: SettingsViewModel, page: Page.Security) {
|
||||
private fun Encryption(state: SettingsState, page: Page.Security) {
|
||||
Column {
|
||||
TextRow("Import room keys", includeDivider = false, onClick = { viewModel.goToImportRoom() })
|
||||
TextRow("Import room keys", includeDivider = false, onClick = { state.dispatch(ScreenAction.OpenImportRoom) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun PushProviders(viewModel: SettingsViewModel, state: Page.PushProviders) {
|
||||
private fun PushProviders(state: SettingsState, page: Page.PushProviders) {
|
||||
LaunchedEffect(true) {
|
||||
viewModel.fetchPushProviders()
|
||||
state.dispatch(RootActions.FetchProviders)
|
||||
}
|
||||
|
||||
when (val lce = state.options) {
|
||||
when (val lce = page.options) {
|
||||
null -> {}
|
||||
is Lce.Loading -> CenteredLoading()
|
||||
is Lce.Content -> {
|
||||
LazyColumn {
|
||||
items(lce.value) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = it == state.selection, onClick = { viewModel.selectPushProvider(it) })
|
||||
RadioButton(selected = it == page.selection, onClick = { state.dispatch(RootActions.SelectPushProvider(it)) })
|
||||
Text(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Lce.Error -> GenericError(cause = lce.cause) { viewModel.fetchPushProviders() }
|
||||
is Lce.Error -> GenericError(cause = lce.cause) { state.dispatch(RootActions.FetchProviders) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SettingsViewModel.ObserveEvents(onSignOut: () -> Unit) {
|
||||
private fun SettingsState.ObserveEvents(onSignOut: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
StartObserving {
|
||||
this@ObserveEvents.events.launch {
|
||||
|
@ -1,182 +0,0 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.ThemeStore
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import app.dapk.st.domain.application.eventlog.LoggingStore
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.push.PushTokenRegistrars
|
||||
import app.dapk.st.push.Registrar
|
||||
import app.dapk.st.settings.SettingItem.Id.*
|
||||
import app.dapk.st.settings.SettingsEvent.*
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import app.dapk.st.viewmodel.MutableStateFactory
|
||||
import app.dapk.st.viewmodel.defaultStateFactory
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
|
||||
internal class SettingsViewModel(
|
||||
private val chatEngine: ChatEngine,
|
||||
private val cacheCleaner: StoreCleaner,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val uriFilenameResolver: UriFilenameResolver,
|
||||
private val settingsItemFactory: SettingsItemFactory,
|
||||
private val pushTokenRegistrars: PushTokenRegistrars,
|
||||
private val themeStore: ThemeStore,
|
||||
private val loggingStore: LoggingStore,
|
||||
private val messageOptionsStore: MessageOptionsStore,
|
||||
factory: MutableStateFactory<SettingsScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<SettingsScreenState, SettingsEvent>(
|
||||
initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))),
|
||||
factory = factory,
|
||||
) {
|
||||
|
||||
fun start() {
|
||||
viewModelScope.launch {
|
||||
val root = Page.Root(Lce.Content(settingsItemFactory.root()))
|
||||
val rootPage = SpiderPage(Page.Routes.root, "Settings", null, root)
|
||||
updateState { copy(page = rootPage) }
|
||||
}
|
||||
}
|
||||
|
||||
fun goTo(page: SpiderPage<out Page>) {
|
||||
updateState { copy(page = page) }
|
||||
}
|
||||
|
||||
fun onClick(item: SettingItem) {
|
||||
when (item.id) {
|
||||
SignOut -> viewModelScope.launch {
|
||||
cacheCleaner.cleanCache(removeCredentials = true)
|
||||
_events.emit(SignedOut)
|
||||
}
|
||||
|
||||
AccessToken -> viewModelScope.launch {
|
||||
require(item is SettingItem.AccessToken)
|
||||
_events.emit(CopyToClipboard("Token copied", item.accessToken))
|
||||
}
|
||||
|
||||
ClearCache -> viewModelScope.launch {
|
||||
cacheCleaner.cleanCache(removeCredentials = false)
|
||||
_events.emit(Toast(message = "Cache deleted"))
|
||||
}
|
||||
|
||||
EventLog -> viewModelScope.launch {
|
||||
_events.emit(OpenEventLog)
|
||||
}
|
||||
|
||||
Encryption -> {
|
||||
updateState {
|
||||
copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security))
|
||||
}
|
||||
}
|
||||
|
||||
PrivacyPolicy -> viewModelScope.launch {
|
||||
_events.emit(OpenUrl(PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
PushProvider -> {
|
||||
updateState {
|
||||
copy(page = SpiderPage(Page.Routes.pushProviders, "Push providers", Page.Routes.root, Page.PushProviders()))
|
||||
}
|
||||
}
|
||||
|
||||
Ignored -> {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
ToggleDynamicTheme -> viewModelScope.launch {
|
||||
themeStore.storeMaterialYouEnabled(!themeStore.isMaterialYouEnabled())
|
||||
refreshRoot()
|
||||
_events.emit(RecreateActivity)
|
||||
|
||||
}
|
||||
|
||||
ToggleEnableLogs -> viewModelScope.launch {
|
||||
loggingStore.setEnabled(!loggingStore.isEnabled())
|
||||
refreshRoot()
|
||||
}
|
||||
|
||||
ToggleSendReadReceipts -> viewModelScope.launch {
|
||||
messageOptionsStore.setReadReceiptsDisabled(!messageOptionsStore.isReadReceiptsDisabled())
|
||||
refreshRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshRoot() {
|
||||
start()
|
||||
}
|
||||
|
||||
fun fetchPushProviders() {
|
||||
updatePageState<Page.PushProviders> { copy(options = Lce.Loading()) }
|
||||
viewModelScope.launch {
|
||||
val currentSelection = pushTokenRegistrars.currentSelection()
|
||||
val options = pushTokenRegistrars.options()
|
||||
updatePageState<Page.PushProviders> {
|
||||
copy(
|
||||
selection = currentSelection,
|
||||
options = Lce.Content(options)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPushProvider(registrar: Registrar) {
|
||||
viewModelScope.launch {
|
||||
pushTokenRegistrars.makeSelection(registrar)
|
||||
fetchPushProviders()
|
||||
}
|
||||
}
|
||||
|
||||
fun importFromFileKeys(file: Uri, passphrase: String) {
|
||||
updatePageState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0)) }
|
||||
viewModelScope.launch {
|
||||
with(chatEngine) {
|
||||
runCatching { contentResolver.openInputStream(file)!! }
|
||||
.fold(
|
||||
onSuccess = { fileStream ->
|
||||
fileStream.importRoomKeys(passphrase)
|
||||
.onEach {
|
||||
updatePageState<Page.ImportRoomKey> { copy(importProgress = it) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
},
|
||||
onFailure = {
|
||||
updatePageState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToImportRoom() {
|
||||
goTo(SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey()))
|
||||
}
|
||||
|
||||
fun fileSelected(file: Uri) {
|
||||
viewModelScope.launch {
|
||||
val namedFile = NamedUri(
|
||||
name = uriFilenameResolver.readFilenameFromUri(file),
|
||||
uri = file
|
||||
)
|
||||
updatePageState<Page.ImportRoomKey> { copy(selectedFile = namedFile) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified S : Page> updatePageState(crossinline block: S.() -> S) {
|
||||
val page = state.page
|
||||
val currentState = page.state
|
||||
require(currentState is S)
|
||||
updateState { copy(page = (page as SpiderPage<S>).copy(state = block(page.state))) }
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package app.dapk.st.settings.state
|
||||
|
||||
import android.net.Uri
|
||||
import app.dapk.st.push.Registrar
|
||||
import app.dapk.st.settings.SettingItem
|
||||
import app.dapk.state.Action
|
||||
|
||||
internal sealed interface ScreenAction : Action {
|
||||
data class OnClick(val item: SettingItem) : ScreenAction
|
||||
object OpenImportRoom : ScreenAction
|
||||
}
|
||||
|
||||
internal sealed interface RootActions : Action {
|
||||
object FetchProviders : RootActions
|
||||
data class SelectPushProvider(val registrar: Registrar) : RootActions
|
||||
data class ImportKeysFromFile(val file: Uri, val passphrase: String) : RootActions
|
||||
data class SelectKeysFile(val file: Uri) : RootActions
|
||||
}
|
||||
|
||||
internal sealed interface ComponentLifecycle : Action {
|
||||
object Visible : ComponentLifecycle
|
||||
}
|
@ -0,0 +1,179 @@
|
||||
package app.dapk.st.settings.state
|
||||
|
||||
import android.content.ContentResolver
|
||||
import app.dapk.st.core.JobBag
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.State
|
||||
import app.dapk.st.core.ThemeStore
|
||||
import app.dapk.st.core.page.*
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import app.dapk.st.domain.application.eventlog.LoggingStore
|
||||
import app.dapk.st.domain.application.message.MessageOptionsStore
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.push.PushTokenRegistrars
|
||||
import app.dapk.st.settings.*
|
||||
import app.dapk.st.settings.SettingItem.Id.*
|
||||
import app.dapk.st.settings.SettingsEvent.*
|
||||
import app.dapk.state.Combined2
|
||||
import app.dapk.state.async
|
||||
import app.dapk.state.createReducer
|
||||
import app.dapk.state.multi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
|
||||
internal fun settingsReducer(
|
||||
chatEngine: ChatEngine,
|
||||
cacheCleaner: StoreCleaner,
|
||||
contentResolver: ContentResolver,
|
||||
uriFilenameResolver: UriFilenameResolver,
|
||||
settingsItemFactory: SettingsItemFactory,
|
||||
pushTokenRegistrars: PushTokenRegistrars,
|
||||
themeStore: ThemeStore,
|
||||
loggingStore: LoggingStore,
|
||||
messageOptionsStore: MessageOptionsStore,
|
||||
eventEmitter: suspend (SettingsEvent) -> Unit,
|
||||
jobBag: JobBag,
|
||||
) = createPageReducer(
|
||||
initialPage = SpiderPage<Page>(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())),
|
||||
factory = {
|
||||
createReducer(
|
||||
initialState = Unit,
|
||||
|
||||
async(ComponentLifecycle.Visible::class) {
|
||||
jobBag.replace("page", coroutineScope.launch {
|
||||
val root = Page.Root(Lce.Content(settingsItemFactory.root()))
|
||||
val rootPage = SpiderPage(Page.Routes.root, "Settings", null, root)
|
||||
dispatch(PageAction.GoTo(rootPage))
|
||||
})
|
||||
},
|
||||
|
||||
async(RootActions.FetchProviders::class) {
|
||||
withPageContext<Page.PushProviders> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(options = Lce.Loading())))
|
||||
}
|
||||
|
||||
val currentSelection = pushTokenRegistrars.currentSelection()
|
||||
val options = pushTokenRegistrars.options()
|
||||
withPageContext<Page.PushProviders> {
|
||||
pageDispatch(
|
||||
PageStateChange.UpdatePage(
|
||||
it.copy(
|
||||
selection = currentSelection,
|
||||
options = Lce.Content(options)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async(RootActions.SelectPushProvider::class) {
|
||||
pushTokenRegistrars.makeSelection(it.registrar)
|
||||
dispatch(RootActions.FetchProviders)
|
||||
},
|
||||
|
||||
async(RootActions.ImportKeysFromFile::class) { action ->
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = ImportResult.Update(0))))
|
||||
}
|
||||
|
||||
with(chatEngine) {
|
||||
runCatching { contentResolver.openInputStream(action.file)!! }
|
||||
.fold(
|
||||
onSuccess = { fileStream ->
|
||||
fileStream.importRoomKeys(action.passphrase)
|
||||
.onEach { progress ->
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = progress)))
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
},
|
||||
onFailure = {
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(importProgress = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile))))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
async(RootActions.SelectKeysFile::class) { action ->
|
||||
val namedFile = NamedUri(
|
||||
name = uriFilenameResolver.readFilenameFromUri(action.file),
|
||||
uri = action.file
|
||||
)
|
||||
|
||||
withPageContext<Page.ImportRoomKey> {
|
||||
pageDispatch(PageStateChange.UpdatePage(it.copy(selectedFile = namedFile)))
|
||||
}
|
||||
},
|
||||
|
||||
async(ScreenAction.OpenImportRoom::class) {
|
||||
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey())))
|
||||
},
|
||||
|
||||
multi(ScreenAction.OnClick::class) { action ->
|
||||
val item = action.item
|
||||
when (item.id) {
|
||||
SignOut -> sideEffect {
|
||||
cacheCleaner.cleanCache(removeCredentials = true)
|
||||
eventEmitter.invoke(SignedOut)
|
||||
}
|
||||
|
||||
AccessToken -> sideEffect {
|
||||
require(item is SettingItem.AccessToken)
|
||||
eventEmitter.invoke(CopyToClipboard("Token copied", item.accessToken))
|
||||
}
|
||||
|
||||
ClearCache -> sideEffect {
|
||||
cacheCleaner.cleanCache(removeCredentials = false)
|
||||
eventEmitter.invoke(Toast(message = "Cache deleted"))
|
||||
}
|
||||
|
||||
Encryption -> async {
|
||||
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security)))
|
||||
}
|
||||
|
||||
PrivacyPolicy -> sideEffect {
|
||||
eventEmitter.invoke(OpenUrl(PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
PushProvider -> async {
|
||||
dispatch(PageAction.GoTo(SpiderPage(Page.Routes.pushProviders, "Push providers", Page.Routes.root, Page.PushProviders())))
|
||||
}
|
||||
|
||||
Ignored -> {
|
||||
nothing()
|
||||
}
|
||||
|
||||
ToggleDynamicTheme -> async {
|
||||
themeStore.storeMaterialYouEnabled(!themeStore.isMaterialYouEnabled())
|
||||
dispatch(ComponentLifecycle.Visible)
|
||||
eventEmitter.invoke(RecreateActivity)
|
||||
}
|
||||
|
||||
ToggleEnableLogs -> async {
|
||||
loggingStore.setEnabled(!loggingStore.isEnabled())
|
||||
dispatch(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
EventLog -> sideEffect {
|
||||
eventEmitter.invoke(OpenEventLog)
|
||||
}
|
||||
|
||||
ToggleSendReadReceipts -> async {
|
||||
messageOptionsStore.setReadReceiptsDisabled(!messageOptionsStore.isReadReceiptsDisabled())
|
||||
dispatch(ComponentLifecycle.Visible)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
internal typealias SettingsState = State<Combined2<PageContainer<Page>, Unit>, SettingsEvent>
|
@ -78,5 +78,6 @@ class FakePushRegistrars {
|
||||
val instance = mockk<PushTokenRegistrars>()
|
||||
|
||||
fun givenCurrentSelection() = coEvery { instance.currentSelection() }.delegateReturn()
|
||||
fun givenOptions() = coEvery { instance.options() }.delegateReturn()
|
||||
|
||||
}
|
@ -0,0 +1,281 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.page.PageAction
|
||||
import app.dapk.st.core.page.PageContainer
|
||||
import app.dapk.st.core.page.PageStateChange
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import app.dapk.st.push.Registrar
|
||||
import app.dapk.st.settings.state.ComponentLifecycle
|
||||
import app.dapk.st.settings.state.RootActions
|
||||
import app.dapk.st.settings.state.ScreenAction
|
||||
import app.dapk.st.settings.state.settingsReducer
|
||||
import app.dapk.state.Combined2
|
||||
import fake.*
|
||||
import fixture.aRoomId
|
||||
import internalfake.FakeSettingsItemFactory
|
||||
import internalfake.FakeUriFilenameResolver
|
||||
import internalfixture.aImportRoomKeysPage
|
||||
import internalfixture.aPushProvidersPage
|
||||
import internalfixture.aSettingTextItem
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
import test.*
|
||||
|
||||
private const val APP_PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
private val A_LIST_OF_ROOT_ITEMS = listOf(aSettingTextItem())
|
||||
private val A_URI = FakeUri()
|
||||
private const val A_FILENAME = "a-filename.jpg"
|
||||
private val AN_INITIAL_IMPORT_ROOM_KEYS_PAGE = aImportRoomKeysPage()
|
||||
private val AN_INITIAL_PUSH_PROVIDERS_PAGE = aPushProvidersPage()
|
||||
private val A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION = aImportRoomKeysPage(
|
||||
state = Page.ImportRoomKey(selectedFile = NamedUri(A_FILENAME, A_URI.instance))
|
||||
)
|
||||
private val A_LIST_OF_ROOM_IDS = listOf(aRoomId())
|
||||
private val AN_IMPORT_SUCCESS = ImportResult.Success(A_LIST_OF_ROOM_IDS.toSet(), totalImportedKeysCount = 5)
|
||||
private val AN_IMPORT_FILE_ERROR = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)
|
||||
private val AN_INPUT_STREAM = FakeInputStream()
|
||||
private const val A_PASSPHRASE = "passphrase"
|
||||
private val AN_ERROR = RuntimeException()
|
||||
private val A_REGISTRAR = Registrar("a-registrar-id")
|
||||
private val A_PUSH_OPTIONS = listOf(Registrar("a-registrar-id"))
|
||||
|
||||
internal class SettingsReducerTest {
|
||||
|
||||
private val fakeStoreCleaner = FakeStoreCleaner()
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
|
||||
private val fakePushTokenRegistrars = FakePushRegistrars()
|
||||
private val fakeSettingsItemFactory = FakeSettingsItemFactory()
|
||||
private val fakeThemeStore = FakeThemeStore()
|
||||
private val fakeLoggingStore = FakeLoggingStore()
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
|
||||
private val runReducerTest = testReducer { fakeEventSource ->
|
||||
settingsReducer(
|
||||
fakeChatEngine,
|
||||
fakeStoreCleaner,
|
||||
fakeContentResolver.instance,
|
||||
fakeUriFilenameResolver.instance,
|
||||
fakeSettingsItemFactory.instance,
|
||||
fakePushTokenRegistrars.instance,
|
||||
fakeThemeStore.instance,
|
||||
fakeLoggingStore.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
fakeEventSource,
|
||||
fakeJobBag.instance,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is root with loading`() = runReducerTest {
|
||||
assertInitialState(
|
||||
pageState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given root content, when Visible, then goes to root page with content`() = runReducerTest {
|
||||
fakeSettingsItemFactory.givenRoot().returns(A_LIST_OF_ROOT_ITEMS)
|
||||
fakeJobBag.instance.expect { it.replace("page", any()) }
|
||||
|
||||
reduce(ComponentLifecycle.Visible)
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageAction.GoTo(
|
||||
SpiderPage(
|
||||
Page.Routes.root,
|
||||
"Settings",
|
||||
null,
|
||||
Page.Root(Lce.Content(A_LIST_OF_ROOT_ITEMS))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when SelectPushProvider, then selects provider and refreshes`() = runReducerTest {
|
||||
fakePushTokenRegistrars.instance.expect { it.makeSelection(A_REGISTRAR) }
|
||||
|
||||
reduce(RootActions.SelectPushProvider(A_REGISTRAR))
|
||||
|
||||
assertOnlyDispatches(RootActions.FetchProviders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when FetchProviders, then selects provider and refreshes`() = runReducerTest {
|
||||
setState(pageState(aPushProvidersPage()))
|
||||
fakePushTokenRegistrars.givenOptions().returns(A_PUSH_OPTIONS)
|
||||
fakePushTokenRegistrars.givenCurrentSelection().returns(A_REGISTRAR)
|
||||
|
||||
reduce(RootActions.FetchProviders)
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
aPushProvidersPage().state.copy(options = Lce.Loading())
|
||||
),
|
||||
PageStateChange.UpdatePage(
|
||||
aPushProvidersPage().state.copy(
|
||||
selection = A_REGISTRAR,
|
||||
options = Lce.Content(A_PUSH_OPTIONS)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when SelectKeysFile, then updates ImportRoomKey page with file`() = runReducerTest {
|
||||
setState(pageState(AN_INITIAL_IMPORT_ROOM_KEYS_PAGE))
|
||||
fakeUriFilenameResolver.givenFilename(A_URI.instance).returns(A_FILENAME)
|
||||
|
||||
reduce(RootActions.SelectKeysFile(A_URI.instance))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
AN_INITIAL_IMPORT_ROOM_KEYS_PAGE.state.copy(
|
||||
selectedFile = NamedUri(A_FILENAME, A_URI.instance)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click SignOut, then clears store and signs out`() = runReducerTest {
|
||||
fakeStoreCleaner.expectUnit { it.cleanCache(removeCredentials = true) }
|
||||
val aSignOutItem = aSettingTextItem(id = SettingItem.Id.SignOut)
|
||||
|
||||
reduce(ScreenAction.OnClick(aSignOutItem))
|
||||
|
||||
assertEvents(SettingsEvent.SignedOut)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click Encryption, then goes to Encryption page`() = runReducerTest {
|
||||
val anEncryptionItem = aSettingTextItem(id = SettingItem.Id.Encryption)
|
||||
|
||||
reduce(ScreenAction.OnClick(anEncryptionItem))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageAction.GoTo(
|
||||
SpiderPage(
|
||||
Page.Routes.encryption,
|
||||
"Encryption",
|
||||
Page.Routes.root,
|
||||
Page.Security
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click PrivacyPolicy, then opens privacy policy url`() = runReducerTest {
|
||||
val aPrivacyPolicyItem = aSettingTextItem(id = SettingItem.Id.PrivacyPolicy)
|
||||
|
||||
reduce(ScreenAction.OnClick(aPrivacyPolicyItem))
|
||||
|
||||
assertOnlyEvents(SettingsEvent.OpenUrl(APP_PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click PushProvider, then goes to PushProvider page`() = runReducerTest {
|
||||
val aPushProviderItem = aSettingTextItem(id = SettingItem.Id.PushProvider)
|
||||
|
||||
reduce(ScreenAction.OnClick(aPushProviderItem))
|
||||
|
||||
assertOnlyDispatches(PageAction.GoTo(aPushProvidersPage()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click Ignored, then does nothing`() = runReducerTest {
|
||||
val anIgnoredItem = aSettingTextItem(id = SettingItem.Id.Ignored)
|
||||
|
||||
reduce(ScreenAction.OnClick(anIgnoredItem))
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click ToggleDynamicTheme, then toggles flag, recreates activity and reloads`() = runReducerTest {
|
||||
val aToggleThemeItem = aSettingTextItem(id = SettingItem.Id.ToggleDynamicTheme)
|
||||
fakeThemeStore.givenMaterialYouIsEnabled().returns(true)
|
||||
fakeThemeStore.instance.expect { it.storeMaterialYouEnabled(false) }
|
||||
|
||||
reduce(ScreenAction.OnClick(aToggleThemeItem))
|
||||
|
||||
assertEvents(SettingsEvent.RecreateActivity)
|
||||
assertDispatches(ComponentLifecycle.Visible)
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click ToggleEnableLogs, then toggles flag and reloads`() = runReducerTest {
|
||||
val aToggleEnableLogsItem = aSettingTextItem(id = SettingItem.Id.ToggleEnableLogs)
|
||||
fakeLoggingStore.givenLoggingIsEnabled().returns(true)
|
||||
fakeLoggingStore.instance.expect { it.setEnabled(false) }
|
||||
|
||||
reduce(ScreenAction.OnClick(aToggleEnableLogsItem))
|
||||
|
||||
assertOnlyDispatches(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click EventLog, then opens event log`() = runReducerTest {
|
||||
val anEventLogItem = aSettingTextItem(id = SettingItem.Id.EventLog)
|
||||
|
||||
reduce(ScreenAction.OnClick(anEventLogItem))
|
||||
|
||||
assertOnlyEvents(SettingsEvent.OpenEventLog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Click ToggleSendReadReceipts, then toggles flag and reloads`() = runReducerTest {
|
||||
val aToggleReadReceiptsItem = aSettingTextItem(id = SettingItem.Id.ToggleSendReadReceipts)
|
||||
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(true)
|
||||
fakeMessageOptionsStore.instance.expect { it.setReadReceiptsDisabled(false) }
|
||||
|
||||
reduce(ScreenAction.OnClick(aToggleReadReceiptsItem))
|
||||
|
||||
assertOnlyDispatches(ComponentLifecycle.Visible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given success, when ImportKeysFromFile, then dispatches progress`() = runReducerTest {
|
||||
setState(pageState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance)
|
||||
fakeChatEngine.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS))
|
||||
|
||||
reduce(RootActions.ImportKeysFromFile(A_URI.instance, A_PASSPHRASE))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = ImportResult.Update(0L))
|
||||
),
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = AN_IMPORT_SUCCESS)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given error, when ImportKeysFromFile, then dispatches error`() = runReducerTest {
|
||||
setState(pageState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
fakeContentResolver.givenFile(A_URI.instance).throws(AN_ERROR)
|
||||
|
||||
reduce(RootActions.ImportKeysFromFile(A_URI.instance, A_PASSPHRASE))
|
||||
|
||||
assertOnlyDispatches(
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = ImportResult.Update(0L))
|
||||
),
|
||||
PageStateChange.UpdatePage(
|
||||
A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION.state.copy(importProgress = AN_IMPORT_FILE_ERROR)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun <P> pageState(page: SpiderPage<out P>) = Combined2(PageContainer(page), Unit)
|
@ -1,210 +0,0 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import ViewModelTest
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.engine.ImportResult
|
||||
import fake.*
|
||||
import fixture.aRoomId
|
||||
import internalfake.FakeSettingsItemFactory
|
||||
import internalfake.FakeUriFilenameResolver
|
||||
import internalfixture.aImportRoomKeysPage
|
||||
import internalfixture.aSettingTextItem
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
|
||||
private const val APP_PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
private val A_LIST_OF_ROOT_ITEMS = listOf(aSettingTextItem())
|
||||
private val A_URI = FakeUri()
|
||||
private const val A_FILENAME = "a-filename.jpg"
|
||||
private val AN_INITIAL_IMPORT_ROOM_KEYS_PAGE = aImportRoomKeysPage()
|
||||
private val A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION = aImportRoomKeysPage(
|
||||
state = Page.ImportRoomKey(selectedFile = NamedUri(A_FILENAME, A_URI.instance))
|
||||
)
|
||||
private val A_LIST_OF_ROOM_IDS = listOf(aRoomId())
|
||||
private val AN_IMPORT_SUCCESS = ImportResult.Success(A_LIST_OF_ROOM_IDS.toSet(), totalImportedKeysCount = 5)
|
||||
private val AN_IMPORT_FILE_ERROR = ImportResult.Error(ImportResult.Error.Type.UnableToOpenFile)
|
||||
private val AN_INPUT_STREAM = FakeInputStream()
|
||||
private const val A_PASSPHRASE = "passphrase"
|
||||
private val AN_ERROR = RuntimeException()
|
||||
|
||||
internal class SettingsViewModelTest {
|
||||
|
||||
private val runViewModelTest = ViewModelTest()
|
||||
|
||||
private val fakeStoreCleaner = FakeStoreCleaner()
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
|
||||
private val fakePushTokenRegistrars = FakePushRegistrars()
|
||||
private val fakeSettingsItemFactory = FakeSettingsItemFactory()
|
||||
private val fakeThemeStore = FakeThemeStore()
|
||||
private val fakeLoggingStore = FakeLoggingStore()
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
|
||||
private val viewModel = SettingsViewModel(
|
||||
fakeChatEngine,
|
||||
fakeStoreCleaner,
|
||||
fakeContentResolver.instance,
|
||||
fakeUriFilenameResolver.instance,
|
||||
fakeSettingsItemFactory.instance,
|
||||
fakePushTokenRegistrars.instance,
|
||||
fakeThemeStore.instance,
|
||||
fakeLoggingStore.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
runViewModelTest.testMutableStateFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when creating view model then initial state is loading Root`() = runViewModelTest {
|
||||
viewModel.test()
|
||||
|
||||
assertInitialState(
|
||||
SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when starting, then emits root page with content`() = runViewModelTest {
|
||||
fakeSettingsItemFactory.givenRoot().returns(A_LIST_OF_ROOT_ITEMS)
|
||||
|
||||
viewModel.test().start()
|
||||
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
SpiderPage(
|
||||
Page.Routes.root,
|
||||
"Settings",
|
||||
null,
|
||||
Page.Root(Lce.Content(A_LIST_OF_ROOT_ITEMS))
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when sign out clicked, then clears store`() = runViewModelTest {
|
||||
fakeStoreCleaner.expectUnit { it.cleanCache(removeCredentials = true) }
|
||||
val aSignOutItem = aSettingTextItem(id = SettingItem.Id.SignOut)
|
||||
|
||||
viewModel.test().onClick(aSignOutItem)
|
||||
|
||||
assertNoStates<SettingsScreenState>()
|
||||
assertEvents(SettingsEvent.SignedOut)
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when event log clicked, then opens event log`() = runViewModelTest {
|
||||
val anEventLogItem = aSettingTextItem(id = SettingItem.Id.EventLog)
|
||||
|
||||
viewModel.test().onClick(anEventLogItem)
|
||||
|
||||
assertNoStates<SettingsScreenState>()
|
||||
assertEvents(SettingsEvent.OpenEventLog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when encryption clicked, then emits encryption page`() = runViewModelTest {
|
||||
val anEncryptionItem = aSettingTextItem(id = SettingItem.Id.Encryption)
|
||||
|
||||
viewModel.test().onClick(anEncryptionItem)
|
||||
|
||||
assertNoEvents<SettingsEvent>()
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
SpiderPage(
|
||||
route = Page.Routes.encryption,
|
||||
label = "Encryption",
|
||||
parent = Page.Routes.root,
|
||||
state = Page.Security
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when privacy policy clicked, then opens privacy policy url`() = runViewModelTest {
|
||||
val aPrivacyPolicyItem = aSettingTextItem(id = SettingItem.Id.PrivacyPolicy)
|
||||
|
||||
viewModel.test().onClick(aPrivacyPolicyItem)
|
||||
|
||||
assertNoStates<SettingsScreenState>()
|
||||
assertEvents(SettingsEvent.OpenUrl(APP_PRIVACY_POLICY_URL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when going to import room, then emits import room keys page`() = runViewModelTest {
|
||||
viewModel.test().goToImportRoom()
|
||||
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
SpiderPage(
|
||||
route = Page.Routes.importRoomKeys,
|
||||
label = "Import room keys",
|
||||
parent = Page.Routes.encryption,
|
||||
state = Page.ImportRoomKey()
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given on import room keys page, when selecting file, then emits selection`() = runViewModelTest {
|
||||
fakeUriFilenameResolver.givenFilename(A_URI.instance).returns(A_FILENAME)
|
||||
|
||||
viewModel.test(initialState = SettingsScreenState(AN_INITIAL_IMPORT_ROOM_KEYS_PAGE)).fileSelected(A_URI.instance)
|
||||
|
||||
assertStates(
|
||||
SettingsScreenState(
|
||||
AN_INITIAL_IMPORT_ROOM_KEYS_PAGE.copy(
|
||||
state = Page.ImportRoomKey(
|
||||
selectedFile = NamedUri(A_FILENAME, A_URI.instance)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given success when importing room keys, then emits progress`() = runViewModelTest {
|
||||
fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance)
|
||||
fakeChatEngine.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(flowOf(AN_IMPORT_SUCCESS))
|
||||
|
||||
viewModel
|
||||
.test(initialState = SettingsScreenState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
.importFromFileKeys(A_URI.instance, A_PASSPHRASE)
|
||||
|
||||
assertStates<SettingsScreenState>(
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0L)) }) },
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = AN_IMPORT_SUCCESS) }) },
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given error when importing room keys, then emits error`() = runViewModelTest {
|
||||
fakeContentResolver.givenFile(A_URI.instance).throws(AN_ERROR)
|
||||
|
||||
viewModel
|
||||
.test(initialState = SettingsScreenState(A_IMPORT_ROOM_KEYS_PAGE_WITH_SELECTION))
|
||||
.importFromFileKeys(A_URI.instance, A_PASSPHRASE)
|
||||
|
||||
assertStates<SettingsScreenState>(
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = ImportResult.Update(0L)) }) },
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = AN_IMPORT_FILE_ERROR) }) },
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified S : Page> SpiderPage<out Page>.updateState(crossinline block: S.() -> S): SpiderPage<Page> {
|
||||
require(this.state is S)
|
||||
return (this as SpiderPage<S>).copy(state = block(this.state)) as SpiderPage<Page>
|
||||
}
|
@ -11,3 +11,12 @@ internal fun aImportRoomKeysPage(
|
||||
parent = Page.Routes.encryption,
|
||||
state = state
|
||||
)
|
||||
|
||||
internal fun aPushProvidersPage(
|
||||
state: Page.PushProviders = Page.PushProviders()
|
||||
) = SpiderPage(
|
||||
route = Page.Routes.pushProviders,
|
||||
label = "Push providers",
|
||||
parent = Page.Routes.root,
|
||||
state = state
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user