Merge pull request #234 from ouchadam/tech/redux-pages

tech/redux pattern
This commit is contained in:
Adam Brown 2022-11-03 09:16:33 +00:00 committed by GitHub
commit 40534bc581
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 899 additions and 554 deletions

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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
)