mirror of
https://github.com/ouchadam/small-talk.git
synced 2025-03-25 16:30:12 +01:00
making a start on testing the android side of the project
This commit is contained in:
parent
b848ee97d2
commit
1f96bfbc0f
@ -115,6 +115,7 @@ internal class AppModule(context: Application, logger: MatrixLogger) {
|
||||
imageLoaderModule,
|
||||
context,
|
||||
buildMeta,
|
||||
coroutineDispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@ -126,6 +127,7 @@ internal class FeatureModules internal constructor(
|
||||
imageLoaderModule: ImageLoaderModule,
|
||||
context: Context,
|
||||
buildMeta: BuildMeta,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
|
||||
val directoryModule by unsafeLazy {
|
||||
@ -156,7 +158,16 @@ internal class FeatureModules internal constructor(
|
||||
)
|
||||
}
|
||||
val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile) }
|
||||
val settingsModule by unsafeLazy { SettingsModule(storeModule.value, matrixModules.crypto, matrixModules.sync, context.contentResolver, buildMeta) }
|
||||
val settingsModule by unsafeLazy {
|
||||
SettingsModule(
|
||||
storeModule.value,
|
||||
matrixModules.crypto,
|
||||
matrixModules.sync,
|
||||
context.contentResolver,
|
||||
buildMeta,
|
||||
coroutineDispatchers
|
||||
)
|
||||
}
|
||||
val profileModule by unsafeLazy { ProfileModule(matrixModules.profile, matrixModules.sync) }
|
||||
val notificationsModule by unsafeLazy {
|
||||
NotificationsModule(
|
||||
@ -195,7 +206,7 @@ internal class MatrixModules(
|
||||
installEncryptionService(store.knownDevicesStore())
|
||||
|
||||
val olmAccountStore = OlmPersistenceWrapper(store.olmStore())
|
||||
val singletonFlows = SingletonFlows()
|
||||
val singletonFlows = SingletonFlows(coroutineDispatchers)
|
||||
val olm = OlmWrapper(
|
||||
olmStore = olmAccountStore,
|
||||
singletonFlows = singletonFlows,
|
||||
|
@ -136,6 +136,12 @@ ext.kotlinFixtures = { dependencies ->
|
||||
dependencies.testFixturesImplementation Dependencies.mavenCentral.kluent
|
||||
}
|
||||
|
||||
ext.androidImportFixturesWorkaround = { project, fixtures ->
|
||||
project.dependencies.testImplementation(project.dependencies.testFixtures(fixtures))
|
||||
project.dependencies.testImplementation fixtures.files("build/libs/${fixtures.name}-test-fixtures.jar")
|
||||
project.dependencies.testImplementation fixtures.files("build/libs/${fixtures.name}.jar")
|
||||
}
|
||||
|
||||
if (launchTask.contains("codeCoverageReport".toLowerCase())) {
|
||||
apply from: 'tools/coverage.gradle'
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
sealed interface Lce<T> {
|
||||
class Loading<T> : Lce<T>
|
||||
data class Loading<T>(val ignored: Unit = Unit) : Lce<T>
|
||||
data class Error<T>(val cause: Throwable) : Lce<T>
|
||||
data class Content<T>(val value: T) : Lce<T>
|
||||
}
|
||||
|
@ -1,18 +1,17 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class SingletonFlows {
|
||||
class SingletonFlows(
|
||||
private val coroutineDispatchers: CoroutineDispatchers
|
||||
) {
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val cache = mutableMapOf<String, MutableSharedFlow<*>>()
|
||||
private val started = ConcurrentHashMap<String, Boolean?>()
|
||||
|
||||
@Suppress("unchecked_cast")
|
||||
suspend fun <T> getOrPut(key: String, onStart: suspend () -> T): Flow<T> {
|
||||
@ -20,7 +19,7 @@ class SingletonFlows {
|
||||
null -> mutex.withLock {
|
||||
cache.getOrPut(key) {
|
||||
MutableSharedFlow<T>(replay = 1).also {
|
||||
withContext(Dispatchers.IO) {
|
||||
coroutineDispatchers.withIoContext {
|
||||
async {
|
||||
it.emit(onStart())
|
||||
}
|
||||
@ -39,9 +38,4 @@ class SingletonFlows {
|
||||
suspend fun <T> update(key: String, value: T) {
|
||||
(cache[key] as? MutableSharedFlow<T>)?.emit(value)
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
cache.remove(key)
|
||||
}
|
||||
|
||||
}
|
@ -6,19 +6,24 @@ import io.mockk.coJustRun
|
||||
import io.mockk.coVerifyAll
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
fun runExpectTest(testBody: suspend ExpectTestScope.() -> Unit) {
|
||||
val expects = mutableListOf<suspend MockKVerificationScope.() -> Unit>()
|
||||
runTest {
|
||||
testBody(object : ExpectTestScope {
|
||||
override val coroutineContext = this@runTest.coroutineContext
|
||||
override fun verifyExpects() = coVerifyAll { expects.forEach { it.invoke(this@coVerifyAll) } }
|
||||
override fun <T> T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) {
|
||||
coJustRun { block(this@expectUnit) }.ignore()
|
||||
expects.add { block(this@expectUnit) }
|
||||
}
|
||||
})
|
||||
runTest { testBody(ExpectTest(coroutineContext)) }
|
||||
}
|
||||
|
||||
|
||||
class ExpectTest(override val coroutineContext: CoroutineContext) : ExpectTestScope {
|
||||
|
||||
private val expects = mutableListOf<suspend MockKVerificationScope.() -> Unit>()
|
||||
|
||||
override fun verifyExpects() = coVerifyAll { expects.forEach { it.invoke(this@coVerifyAll) } }
|
||||
|
||||
override fun <T> T.expectUnit(block: suspend MockKMatcherScope.(T) -> Unit) {
|
||||
coJustRun { block(this@expectUnit) }.ignore()
|
||||
expects.add { block(this@expectUnit) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun Any.ignore() = Unit
|
||||
|
@ -6,10 +6,22 @@ inline fun <T : Any, reified R> T.expect(crossinline block: suspend MockKMatcher
|
||||
coEvery { block(this@expect) } returns mockk(relaxed = true)
|
||||
}
|
||||
|
||||
fun <T, B> MockKStubScope<T, B>.delegateReturn(): Returns<T> = Returns { value ->
|
||||
answers(ConstantAnswer(value))
|
||||
fun <T, B> MockKStubScope<T, B>.delegateReturn() = object : Returns<T> {
|
||||
override fun returns(value: T) {
|
||||
answers(ConstantAnswer(value))
|
||||
}
|
||||
|
||||
override fun throws(value: Throwable) {
|
||||
this@delegateReturn.throws(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface Returns<T> {
|
||||
fun returns(value: T)
|
||||
fun <T> returns(block: (T) -> Unit) = object : Returns<T> {
|
||||
override fun returns(value: T) = block(value)
|
||||
override fun throws(value: Throwable) = throw value
|
||||
}
|
||||
|
||||
interface Returns<T> {
|
||||
fun returns(value: T)
|
||||
fun throws(value: Throwable)
|
||||
}
|
||||
|
1
domains/android/viewmodel-stub/build.gradle
Normal file
1
domains/android/viewmodel-stub/build.gradle
Normal file
@ -0,0 +1 @@
|
||||
plugins { id 'kotlin' }
|
@ -0,0 +1,23 @@
|
||||
@file:JvmName("SnapshotStateKt")
|
||||
@file:JvmMultifileClass
|
||||
|
||||
package androidx.compose.runtime
|
||||
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
interface State<out T> {
|
||||
val value: T
|
||||
}
|
||||
|
||||
interface MutableState<T> : State<T> {
|
||||
override var value: T
|
||||
operator fun component1(): T
|
||||
operator fun component2(): (T) -> Unit
|
||||
}
|
||||
|
||||
operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = throw RuntimeException("stub")
|
||||
operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
|
||||
throw RuntimeException("stub")
|
||||
}
|
||||
|
||||
fun <T> mutableStateOf(value: T): MutableState<T> = throw RuntimeException("stub")
|
@ -0,0 +1,19 @@
|
||||
package androidx.lifecycle
|
||||
|
||||
abstract class ViewModel {
|
||||
|
||||
protected open fun onCleared() {}
|
||||
|
||||
fun clear() {
|
||||
throw RuntimeException("stub")
|
||||
}
|
||||
|
||||
fun <T> setTagIfAbsent(key: String, newValue: T): T {
|
||||
throw RuntimeException("stub")
|
||||
}
|
||||
|
||||
fun <T> getTag(key: String): T? {
|
||||
throw RuntimeException("stub")
|
||||
}
|
||||
|
||||
}
|
15
domains/android/viewmodel/build.gradle
Normal file
15
domains/android/viewmodel/build.gradle
Normal file
@ -0,0 +1,15 @@
|
||||
plugins {
|
||||
id 'kotlin'
|
||||
id 'java-test-fixtures'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly project(":domains:android:viewmodel-stub")
|
||||
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
|
||||
kotlinFixtures(it)
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
testFixturesImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testFixturesImplementation testFixtures(project(":core"))
|
||||
testFixturesCompileOnly project(":domains:android:viewmodel-stub")
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
package app.dapk.st.core
|
||||
package app.dapk.st.viewmodel
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@ -7,16 +8,21 @@ import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
abstract class DapkViewModel<S, VE>(
|
||||
initialState: S
|
||||
) : ViewModel() {
|
||||
typealias MutableStateFactory <S> = (S) -> MutableState<S>
|
||||
|
||||
fun <S> defaultStateFactory(): MutableStateFactory<S> = { mutableStateOf(it) }
|
||||
|
||||
@Suppress("PropertyName")
|
||||
abstract class DapkViewModel<S, VE>(initialState: S, factory: MutableStateFactory<S> = defaultStateFactory()) : ViewModel() {
|
||||
|
||||
protected val _events = MutableSharedFlow<VE>(extraBufferCapacity = 1)
|
||||
val events: SharedFlow<VE> = _events
|
||||
var state by mutableStateOf<S>(initialState)
|
||||
|
||||
var state by factory(initialState)
|
||||
protected set
|
||||
|
||||
fun updateState(reducer: S.() -> S) {
|
||||
state = reducer(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,17 @@
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.amshove.kluent.internal.assertEquals
|
||||
|
||||
class FlowTestObserver<T>(scope: CoroutineScope, flow: Flow<T>) {
|
||||
|
||||
private val values = mutableListOf<T>()
|
||||
private val job: Job = flow
|
||||
.onEach { values.add(it) }
|
||||
.launchIn(scope)
|
||||
|
||||
fun assertValues(values: List<T>) = assertEquals(values, this.values)
|
||||
fun finish() = job.cancel()
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import androidx.compose.runtime.MutableState
|
||||
|
||||
class TestMutableState<T>(initialState: T) : MutableState<T> {
|
||||
|
||||
private var _value: T = initialState
|
||||
|
||||
var onValue: ((T) -> Unit)? = null
|
||||
|
||||
override var value: T
|
||||
get() = _value
|
||||
set(value) {
|
||||
_value = value
|
||||
onValue?.invoke(value)
|
||||
}
|
||||
|
||||
override fun component1(): T = throw RuntimeException("stub")
|
||||
override fun component2(): (T) -> Unit = throw RuntimeException("stub")
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import app.dapk.st.viewmodel.MutableStateFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import test.ExpectTest
|
||||
|
||||
class ViewModelTest {
|
||||
|
||||
var instance: TestMutableState<Any>? = null
|
||||
|
||||
fun <S> testMutableStateFactory(): MutableStateFactory<S> {
|
||||
return { TestMutableState(it).also { instance = it as TestMutableState<Any> } }
|
||||
}
|
||||
|
||||
operator fun invoke(block: suspend ViewModelTestScope.() -> Unit) {
|
||||
runTest {
|
||||
val expectTest = ExpectTest(coroutineContext)
|
||||
val viewModelTest = ViewModelTestScopeImpl(expectTest, this@ViewModelTest)
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
|
||||
block(viewModelTest)
|
||||
viewModelTest.finish()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.amshove.kluent.internal.assertEquals
|
||||
import test.ExpectTestScope
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal class ViewModelTestScopeImpl(
|
||||
private val expectTestScope: ExpectTestScope,
|
||||
private val stateProvider: ViewModelTest
|
||||
) : ExpectTestScope by expectTestScope, ViewModelTestScope {
|
||||
|
||||
private val flowObserverScope = CoroutineScope(Dispatchers.Unconfined)
|
||||
|
||||
private var viewEvents: FlowTestObserver<*>? = null
|
||||
private val viewStates = mutableListOf<Any>()
|
||||
private var capturedInitialState: Any? = null
|
||||
|
||||
override fun <S, VE, T : DapkViewModel<S, VE>> T.test(initialState: S?): T {
|
||||
initialState?.let { stateProvider.instance?.value = it }
|
||||
capturedInitialState = stateProvider.instance?.value
|
||||
stateProvider.instance?.onValue = { viewStates.add(it) }
|
||||
viewEvents = FlowTestObserver(flowObserverScope, this.events)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun <S> assertStates(states: List<S>) {
|
||||
assertEquals(states, viewStates)
|
||||
}
|
||||
|
||||
override fun <VE> assertEvents(events: List<VE>) {
|
||||
(viewEvents as? FlowTestObserver<VE>)?.assertValues(events)
|
||||
}
|
||||
|
||||
override fun <S> assertInitialState(state: S) {
|
||||
assertEquals(state, capturedInitialState)
|
||||
}
|
||||
|
||||
override fun <S> assertStates(vararg state: (S) -> S) {
|
||||
val states = state.toList().map { it as (Any) -> Any }.fold(mutableListOf<Any>()) { acc, curr ->
|
||||
curr.invoke(acc.lastOrNull() ?: capturedInitialState!!).let { acc.add(it) }; acc
|
||||
}
|
||||
assertStates(states)
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
viewStates.clear()
|
||||
viewEvents?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
interface ViewModelTestScope : ExpectTestScope {
|
||||
fun <S, VE, T : DapkViewModel<S, VE>> T.test(initialState: S? = null): T
|
||||
|
||||
fun <VE> assertEvents(vararg event: VE) = this.assertEvents(event.toList())
|
||||
fun <VE> assertNoEvents() = this.assertEvents<VE>(emptyList())
|
||||
fun <VE> assertEvents(events: List<VE>)
|
||||
|
||||
fun <S> assertInitialState(state: S)
|
||||
fun <S> assertStates(vararg state: S.() -> S)
|
||||
fun <S> assertStates(vararg state: S) = this.assertStates(state.toList())
|
||||
fun <S> assertNoStates() = this.assertStates<S>(emptyList())
|
||||
fun <S> assertStates(states: List<S>)
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ dependencies {
|
||||
implementation project(":matrix:services:message")
|
||||
implementation project(":matrix:services:room")
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":features:messenger")
|
||||
implementation project(":core")
|
||||
implementation project(":design-library")
|
||||
|
@ -1,9 +1,9 @@
|
||||
package app.dapk.st.directory
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.directory.DirectoryScreenState.Content
|
||||
import app.dapk.st.directory.DirectoryScreenState.EmptyLoading
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
@ -8,6 +8,7 @@ dependencies {
|
||||
implementation project(":features:settings")
|
||||
implementation project(":features:profile")
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(':domains:store')
|
||||
implementation project(":core")
|
||||
implementation project(":design-library")
|
||||
|
@ -1,7 +1,6 @@
|
||||
package app.dapk.st.home
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.directory.DirectoryViewModel
|
||||
import app.dapk.st.home.HomeScreenState.*
|
||||
import app.dapk.st.login.LoginViewModel
|
||||
@ -9,6 +8,7 @@ import app.dapk.st.matrix.common.CredentialsStore
|
||||
import app.dapk.st.matrix.common.isSignedIn
|
||||
import app.dapk.st.matrix.room.ProfileService
|
||||
import app.dapk.st.profile.ProfileViewModel
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HomeViewModel(
|
||||
|
@ -3,6 +3,7 @@ applyAndroidLibraryModule(project)
|
||||
dependencies {
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:push")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":matrix:services:auth")
|
||||
implementation project(":matrix:services:profile")
|
||||
implementation project(":matrix:services:crypto")
|
||||
|
@ -1,15 +1,14 @@
|
||||
package app.dapk.st.login
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.core.extensions.ErrorTracker
|
||||
import app.dapk.st.core.logP
|
||||
import app.dapk.st.login.LoginEvent.LoginComplete
|
||||
import app.dapk.st.login.LoginScreenState.*
|
||||
import app.dapk.st.matrix.auth.AuthService
|
||||
import app.dapk.st.matrix.crypto.CryptoService
|
||||
import app.dapk.st.matrix.room.ProfileService
|
||||
import app.dapk.st.push.RegisterFirebasePushTokenUseCase
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -6,6 +6,7 @@ dependencies {
|
||||
implementation project(":matrix:services:message")
|
||||
implementation project(":matrix:services:room")
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":core")
|
||||
implementation project(":features:navigator")
|
||||
implementation project(":design-library")
|
||||
|
@ -1,7 +1,6 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.core.extensions.takeIfContent
|
||||
import app.dapk.st.matrix.common.CredentialsStore
|
||||
@ -11,6 +10,7 @@ import app.dapk.st.matrix.room.RoomService
|
||||
import app.dapk.st.matrix.sync.RoomEvent
|
||||
import app.dapk.st.matrix.sync.RoomStore
|
||||
import app.dapk.st.matrix.sync.SyncService
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
@ -6,6 +6,7 @@ dependencies {
|
||||
implementation project(":features:settings")
|
||||
implementation project(':domains:store')
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":design-library")
|
||||
implementation project(":core")
|
||||
}
|
@ -1,10 +1,9 @@
|
||||
package app.dapk.st.profile
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.matrix.room.ProfileService
|
||||
import app.dapk.st.matrix.sync.SyncService
|
||||
import kotlinx.coroutines.flow.count
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
@ -6,6 +6,13 @@ dependencies {
|
||||
implementation project(":features:navigator")
|
||||
implementation project(':domains:store')
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":design-library")
|
||||
implementation project(":core")
|
||||
|
||||
kotlinTest(it)
|
||||
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import app.dapk.st.core.BuildMeta
|
||||
|
||||
internal class SettingsItemFactory(private val buildMeta: BuildMeta) {
|
||||
|
||||
fun root() = listOf(
|
||||
SettingItem.Header("General"),
|
||||
SettingItem.Text(SettingItem.Id.Encryption, "Encryption"),
|
||||
SettingItem.Text(SettingItem.Id.EventLog, "Event log"),
|
||||
SettingItem.Header("Account"),
|
||||
SettingItem.Text(SettingItem.Id.SignOut, "Sign out"),
|
||||
SettingItem.Header("About"),
|
||||
SettingItem.Text(SettingItem.Id.PrivacyPolicy, "Privacy policy"),
|
||||
SettingItem.Text(SettingItem.Id.Ignored, "Version", buildMeta.versionName),
|
||||
)
|
||||
|
||||
}
|
@ -2,6 +2,7 @@ package app.dapk.st.settings
|
||||
|
||||
import android.content.ContentResolver
|
||||
import app.dapk.st.core.BuildMeta
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.domain.StoreModule
|
||||
import app.dapk.st.matrix.crypto.CryptoService
|
||||
@ -14,19 +15,20 @@ class SettingsModule(
|
||||
private val syncService: SyncService,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun settingsViewModel() = SettingsViewModel(
|
||||
internal fun settingsViewModel() = SettingsViewModel(
|
||||
storeModule.credentialsStore(),
|
||||
storeModule.cacheCleaner(),
|
||||
contentResolver,
|
||||
cryptoService,
|
||||
syncService,
|
||||
UriFilenameResolver(contentResolver),
|
||||
buildMeta
|
||||
UriFilenameResolver(contentResolver, coroutineDispatchers),
|
||||
SettingsItemFactory(buildMeta),
|
||||
)
|
||||
|
||||
fun eventLogViewModel(): EventLoggerViewModel {
|
||||
internal fun eventLogViewModel(): EventLoggerViewModel {
|
||||
return EventLoggerViewModel(storeModule.eventLogStore())
|
||||
}
|
||||
}
|
@ -46,7 +46,7 @@ import app.dapk.st.settings.SettingsEvent.*
|
||||
import app.dapk.st.settings.eventlogger.EventLogActivity
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, navigator: Navigator) {
|
||||
internal fun SettingsScreen(viewModel: SettingsViewModel, onSignOut: () -> Unit, navigator: Navigator) {
|
||||
viewModel.ObserveEvents(onSignOut)
|
||||
LaunchedEffect(true) {
|
||||
viewModel.start()
|
||||
|
@ -14,7 +14,7 @@ sealed interface Page {
|
||||
object Security : Page
|
||||
data class ImportRoomKey(
|
||||
val selectedFile: NamedUri? = null,
|
||||
val importProgress: Lce<Boolean>? = null,
|
||||
val importProgress: Lce<Unit>? = null,
|
||||
) : Page
|
||||
|
||||
object Routes {
|
||||
|
@ -3,8 +3,6 @@ package app.dapk.st.settings
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.BuildMeta
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
@ -13,38 +11,33 @@ import app.dapk.st.matrix.crypto.CryptoService
|
||||
import app.dapk.st.matrix.sync.SyncService
|
||||
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.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private const val PRIVACY_POLICY_URL = "https://ouchadam.github.io/small-talk/privacy/"
|
||||
|
||||
internal class SettingsViewModel(
|
||||
private val credentialsStore: CredentialsStore,
|
||||
private val cacheCleaner: StoreCleaner,
|
||||
private val contentResolver: ContentResolver,
|
||||
private val cryptoService: CryptoService,
|
||||
private val syncService: SyncService,
|
||||
private val uriFilenameResolver: UriFilenameResolver,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val settingsItemFactory: SettingsItemFactory,
|
||||
factory: MutableStateFactory<SettingsScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<SettingsScreenState, SettingsEvent>(
|
||||
initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading())))
|
||||
initialState = SettingsScreenState(SpiderPage(Page.Routes.root, "Settings", null, Page.Root(Lce.Loading()))),
|
||||
factory = factory,
|
||||
) {
|
||||
|
||||
fun start() {
|
||||
viewModelScope.launch {
|
||||
val root = Page.Root(
|
||||
Lce.Content(
|
||||
listOf(
|
||||
SettingItem.Header("General"),
|
||||
SettingItem.Text(Encryption, "Encryption"),
|
||||
SettingItem.Text(EventLog, "Event log"),
|
||||
SettingItem.Header("Account"),
|
||||
SettingItem.Text(SignOut, "Sign out"),
|
||||
SettingItem.Header("About"),
|
||||
SettingItem.Text(PrivacyPolicy, "Privacy policy"),
|
||||
SettingItem.Text(Ignored, "Version", buildMeta.versionName),
|
||||
)
|
||||
)
|
||||
)
|
||||
val root = Page.Root(Lce.Content(settingsItemFactory.root()))
|
||||
val rootPage = SpiderPage(Page.Routes.root, "Settings", null, root)
|
||||
updateState { copy(page = rootPage) }
|
||||
println("state updated")
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,28 +48,39 @@ class SettingsViewModel(
|
||||
fun onClick(item: SettingItem) {
|
||||
when (item.id) {
|
||||
SignOut -> {
|
||||
viewModelScope.launch { credentialsStore.clear() }
|
||||
_events.tryEmit(SignedOut)
|
||||
viewModelScope.launch {
|
||||
credentialsStore.clear()
|
||||
_events.emit(SignedOut)
|
||||
println("emitted")
|
||||
}
|
||||
}
|
||||
AccessToken -> {
|
||||
require(item is SettingItem.AccessToken)
|
||||
_events.tryEmit(CopyToClipboard("Token copied", item.accessToken))
|
||||
viewModelScope.launch {
|
||||
require(item is SettingItem.AccessToken)
|
||||
_events.emit(CopyToClipboard("Token copied", item.accessToken))
|
||||
}
|
||||
}
|
||||
ClearCache -> {
|
||||
viewModelScope.launch {
|
||||
cacheCleaner.cleanCache(removeCredentials = false)
|
||||
_events.tryEmit(Toast(message = "Cache deleted"))
|
||||
_events.emit(Toast(message = "Cache deleted"))
|
||||
}
|
||||
}
|
||||
EventLog -> {
|
||||
_events.tryEmit(OpenEventLog)
|
||||
viewModelScope.launch {
|
||||
_events.emit(OpenEventLog)
|
||||
}
|
||||
}
|
||||
Encryption -> {
|
||||
updateState {
|
||||
copy(page = SpiderPage(Page.Routes.encryption, "Encryption", Page.Routes.root, Page.Security))
|
||||
}
|
||||
}
|
||||
PrivacyPolicy -> _events.tryEmit(OpenUrl("https://ouchadam.github.io/small-talk/privacy/"))
|
||||
PrivacyPolicy -> {
|
||||
viewModelScope.launch {
|
||||
_events.emit(OpenUrl(PRIVACY_POLICY_URL))
|
||||
}
|
||||
}
|
||||
Ignored -> {
|
||||
// do nothing
|
||||
}
|
||||
@ -92,24 +96,24 @@ class SettingsViewModel(
|
||||
roomsToRefresh?.let { syncService.forceManualRefresh(roomsToRefresh) }
|
||||
}
|
||||
}.fold(
|
||||
onSuccess = { updatePageState<Page.ImportRoomKey> { copy(importProgress = Lce.Content(true)) } },
|
||||
onSuccess = { updatePageState<Page.ImportRoomKey> { copy(importProgress = Lce.Content(Unit)) } },
|
||||
onFailure = { updatePageState<Page.ImportRoomKey> { copy(importProgress = Lce.Error(it)) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun goToImportRoom() {
|
||||
updateState {
|
||||
copy(page = SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey()))
|
||||
}
|
||||
goTo(SpiderPage(Page.Routes.importRoomKeys, "Import room keys", Page.Routes.encryption, Page.ImportRoomKey()))
|
||||
}
|
||||
|
||||
fun fileSelected(file: Uri) {
|
||||
val namedFile = NamedUri(
|
||||
name = uriFilenameResolver.readFilenameFromUri(file),
|
||||
uri = file
|
||||
)
|
||||
updatePageState<Page.ImportRoomKey> { copy(selectedFile = namedFile) }
|
||||
viewModelScope.launch {
|
||||
val namedFile = NamedUri(
|
||||
name = uriFilenameResolver.readFilenameFromUri(file),
|
||||
uri = file
|
||||
)
|
||||
updatePageState<Page.ImportRoomKey> { copy(selectedFile = namedFile) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
|
@ -3,10 +3,15 @@ package app.dapk.st.settings
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import app.dapk.st.core.withIoContext
|
||||
|
||||
class UriFilenameResolver(private val contentResolver: ContentResolver) {
|
||||
class UriFilenameResolver(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val coroutineDispatchers: CoroutineDispatchers
|
||||
) {
|
||||
|
||||
fun readFilenameFromUri(uri: Uri): String {
|
||||
suspend fun readFilenameFromUri(uri: Uri): String {
|
||||
val fallback = uri.path?.substringAfterLast('/') ?: throw IllegalStateException("expecting a file uri but got $uri")
|
||||
return when (uri.scheme) {
|
||||
"content" -> readResolvedDisplayName(uri) ?: fallback
|
||||
@ -14,15 +19,17 @@ class UriFilenameResolver(private val contentResolver: ContentResolver) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun readResolvedDisplayName(uri: Uri): String? {
|
||||
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
when {
|
||||
cursor.moveToFirst() -> {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
.takeIf { it != -1 }
|
||||
?.let { cursor.getString(it) }
|
||||
private suspend fun readResolvedDisplayName(uri: Uri): String? {
|
||||
return coroutineDispatchers.withIoContext {
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
when {
|
||||
cursor.moveToFirst() -> {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
.takeIf { it != -1 }
|
||||
?.let { cursor.getString(it) }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
package app.dapk.st.settings.eventlogger
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.domain.eventlog.EventLogPersistence
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
@ -0,0 +1,27 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import android.database.Cursor
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeCursor {
|
||||
|
||||
val instance = mockk<Cursor>()
|
||||
|
||||
init {
|
||||
every { instance.close() } answers {}
|
||||
}
|
||||
|
||||
fun givenEmpty() {
|
||||
every { instance.count } returns 0
|
||||
every { instance.moveToFirst() } returns false
|
||||
}
|
||||
|
||||
fun givenString(columnName: String, content: String?) {
|
||||
val columnId = columnName.hashCode()
|
||||
every { instance.moveToFirst() } returns true
|
||||
every { instance.isNull(columnId) } returns (content == null)
|
||||
every { instance.getColumnIndex(columnName) } returns columnId
|
||||
every { instance.getString(columnId) } returns content
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import app.dapk.st.core.BuildMeta
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsItemFactoryTest {
|
||||
|
||||
private val buildMeta = BuildMeta(versionName = "a-version-name")
|
||||
|
||||
private val settingsItemFactory = SettingsItemFactory(buildMeta)
|
||||
|
||||
@Test
|
||||
fun `when creating root items, then is expected`() {
|
||||
val result = settingsItemFactory.root()
|
||||
|
||||
result shouldBeEqualTo listOf(
|
||||
aSettingHeaderItem("General"),
|
||||
aSettingTextItem(SettingItem.Id.Encryption, "Encryption"),
|
||||
aSettingTextItem(SettingItem.Id.EventLog, "Event log"),
|
||||
aSettingHeaderItem("Account"),
|
||||
aSettingTextItem(SettingItem.Id.SignOut, "Sign out"),
|
||||
aSettingHeaderItem("About"),
|
||||
aSettingTextItem(SettingItem.Id.PrivacyPolicy, "Privacy policy"),
|
||||
aSettingTextItem(SettingItem.Id.Ignored, "Version", buildMeta.versionName),
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,273 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import ViewModelTest
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import app.dapk.st.core.Lce
|
||||
import app.dapk.st.design.components.SpiderPage
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import app.dapk.st.matrix.crypto.CryptoService
|
||||
import app.dapk.st.matrix.sync.SyncService
|
||||
import fake.FakeCredentialsStore
|
||||
import fixture.aRoomId
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Test
|
||||
import test.delegateReturn
|
||||
import java.io.InputStream
|
||||
|
||||
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_INPUT_STREAM = FakeInputStream()
|
||||
private const val A_PASSPHRASE = "passphrase"
|
||||
private val AN_ERROR = RuntimeException()
|
||||
|
||||
internal class SettingsViewModelTest {
|
||||
|
||||
private val runViewModelTest = ViewModelTest()
|
||||
|
||||
private val fakeCredentialsStore = FakeCredentialsStore()
|
||||
private val fakeStoreCleaner = FakeStoreCleaner()
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val fakeCryptoService = FakeCryptoService()
|
||||
private val fakeSyncService = FakeSyncService()
|
||||
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
|
||||
private val fakeSettingsItemFactory = FakeSettingsItemFactory()
|
||||
|
||||
private val viewModel = SettingsViewModel(
|
||||
fakeCredentialsStore,
|
||||
fakeStoreCleaner,
|
||||
fakeContentResolver.instance,
|
||||
fakeCryptoService,
|
||||
fakeSyncService,
|
||||
fakeUriFilenameResolver.instance,
|
||||
fakeSettingsItemFactory.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 credentials`() = runViewModelTest {
|
||||
fakeCredentialsStore.expectUnit { it.clear() }
|
||||
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 {
|
||||
fakeSyncService.expectUnit { it.forceManualRefresh(A_LIST_OF_ROOM_IDS) }
|
||||
fakeContentResolver.givenFile(A_URI.instance).returns(AN_INPUT_STREAM.instance)
|
||||
fakeCryptoService.givenImportKeys(AN_INPUT_STREAM.instance, A_PASSPHRASE).returns(A_LIST_OF_ROOM_IDS)
|
||||
|
||||
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 = Lce.Loading()) }) },
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = Lce.Content(Unit)) }) },
|
||||
)
|
||||
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 = Lce.Loading()) }) },
|
||||
{ copy(page = page.updateState<Page.ImportRoomKey> { copy(importProgress = Lce.Error(AN_ERROR)) }) },
|
||||
)
|
||||
assertNoEvents<SettingsEvent>()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class FakeInputStream {
|
||||
val instance = mockk<InputStream>()
|
||||
}
|
||||
|
||||
fun aSettingTextItem(
|
||||
id: SettingItem.Id = SettingItem.Id.Ignored,
|
||||
content: String = "text-content",
|
||||
subtitle: String? = null
|
||||
) = SettingItem.Text(id, content, subtitle)
|
||||
|
||||
fun aSettingHeaderItem(
|
||||
label: String = "header-label",
|
||||
) = SettingItem.Header(label)
|
||||
|
||||
class FakeContentResolver {
|
||||
val instance = mockk<ContentResolver>()
|
||||
|
||||
fun givenFile(uri: Uri) = every { instance.openInputStream(uri) }.delegateReturn()
|
||||
|
||||
fun givenUriResult(uri: Uri) = every { instance.query(uri, null, null, null, null) }.delegateReturn()
|
||||
}
|
||||
|
||||
internal class FakeSettingsItemFactory {
|
||||
val instance = mockk<SettingsItemFactory>()
|
||||
|
||||
fun givenRoot() = every { instance.root() }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeStoreCleaner : StoreCleaner by mockk()
|
||||
class FakeCryptoService : CryptoService by mockk() {
|
||||
fun givenImportKeys(inputStream: InputStream, passphrase: String) = coEvery { inputStream.importRoomKeys(passphrase) }.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeSyncService : SyncService by mockk()
|
||||
class FakeUriFilenameResolver {
|
||||
val instance = mockk<UriFilenameResolver>()
|
||||
|
||||
fun givenFilename(uri: Uri) = coEvery { instance.readFilenameFromUri(uri) }
|
||||
}
|
||||
|
||||
|
||||
class FakeUri {
|
||||
val instance = mockk<Uri>()
|
||||
|
||||
fun givenNonHierarchical() {
|
||||
givenContent(schema = "mail", path = null)
|
||||
}
|
||||
|
||||
fun givenContent(schema: String, path: String?) {
|
||||
every { instance.scheme } returns schema
|
||||
every { instance.path } returns path
|
||||
}
|
||||
}
|
||||
|
||||
@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>
|
||||
}
|
||||
|
||||
fun aImportRoomKeysPage(
|
||||
state: Page.ImportRoomKey = Page.ImportRoomKey()
|
||||
) = SpiderPage(
|
||||
route = Page.Routes.importRoomKeys,
|
||||
label = "Import room keys",
|
||||
parent = Page.Routes.encryption,
|
||||
state = state
|
||||
)
|
@ -0,0 +1,68 @@
|
||||
package app.dapk.st.settings
|
||||
|
||||
import android.provider.OpenableColumns
|
||||
import app.dapk.st.core.CoroutineDispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
private const val A_LAST_SEGMENT = "a-file-name.foo"
|
||||
private const val A_DISPLAY_NAME = "file-display-name.foo"
|
||||
|
||||
class UriFilenameResolverTest {
|
||||
|
||||
private val fakeUri = FakeUri()
|
||||
private val fakeContentResolver = FakeContentResolver()
|
||||
private val uriFilenameResolver = UriFilenameResolver(fakeContentResolver.instance, CoroutineDispatchers())
|
||||
|
||||
@Test
|
||||
fun `given a non hierarchical Uri when querying file name then throws`() = runTest {
|
||||
assertFailsWith<IllegalStateException> {
|
||||
fakeUri.givenNonHierarchical()
|
||||
|
||||
uriFilenameResolver.readFilenameFromUri(fakeUri.instance)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given a non content schema Uri when querying file name then returns last segment`() = runTest {
|
||||
fakeUri.givenContent(schema = "file", path = "path/to/$A_LAST_SEGMENT")
|
||||
|
||||
val result = uriFilenameResolver.readFilenameFromUri(fakeUri.instance)
|
||||
|
||||
result shouldBeEqualTo A_LAST_SEGMENT
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given content schema Uri with no backing content when querying file name then returns last segment`() = runTest {
|
||||
fakeUri.givenContent(schema = "content", path = "path/to/$A_LAST_SEGMENT")
|
||||
fakeContentResolver.givenUriResult(fakeUri.instance).returns(null)
|
||||
|
||||
val result = uriFilenameResolver.readFilenameFromUri(fakeUri.instance)
|
||||
|
||||
result shouldBeEqualTo A_LAST_SEGMENT
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given content schema Uri with empty backing content when querying file name then returns last segment`() = runTest {
|
||||
fakeUri.givenContent(schema = "content", path = "path/to/$A_LAST_SEGMENT")
|
||||
val emptyCursor = FakeCursor().also { it.givenEmpty() }
|
||||
fakeContentResolver.givenUriResult(fakeUri.instance).returns(emptyCursor.instance)
|
||||
|
||||
val result = uriFilenameResolver.readFilenameFromUri(fakeUri.instance)
|
||||
|
||||
result shouldBeEqualTo A_LAST_SEGMENT
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given content schema Uri with backing content when querying file name then returns display name column`() = runTest {
|
||||
fakeUri.givenContent(schema = "content", path = "path/to/$A_DISPLAY_NAME")
|
||||
val aCursor = FakeCursor().also { it.givenString(OpenableColumns.DISPLAY_NAME, A_DISPLAY_NAME) }
|
||||
fakeContentResolver.givenUriResult(fakeUri.instance).returns(aCursor.instance)
|
||||
|
||||
val result = uriFilenameResolver.readFilenameFromUri(fakeUri.instance)
|
||||
|
||||
result shouldBeEqualTo A_DISPLAY_NAME
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ applyAndroidLibraryModule(project)
|
||||
dependencies {
|
||||
implementation project(":matrix:services:crypto")
|
||||
implementation project(":domains:android:core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":design-library")
|
||||
implementation project(":core")
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
package app.dapk.st.verification
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.core.DapkViewModel
|
||||
import app.dapk.st.matrix.crypto.CryptoService
|
||||
import app.dapk.st.matrix.crypto.Verification
|
||||
import app.dapk.st.viewmodel.DapkViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class VerificationViewModel(
|
||||
|
@ -0,0 +1,10 @@
|
||||
package fixture
|
||||
|
||||
import app.dapk.st.matrix.common.DecryptionResult
|
||||
import app.dapk.st.matrix.common.JsonString
|
||||
|
||||
fun aDecryptionSuccessResult(
|
||||
payload: JsonString = aJsonString(),
|
||||
isVerified: Boolean = false,
|
||||
) = DecryptionResult.Success(payload, isVerified)
|
||||
|
@ -0,0 +1,21 @@
|
||||
package fixture
|
||||
|
||||
import app.dapk.st.matrix.common.*
|
||||
|
||||
fun anEncryptedMegOlmV1Message(
|
||||
cipherText: CipherText = aCipherText(),
|
||||
deviceId: DeviceId = aDeviceId(),
|
||||
senderKey: String = "a-sender-key",
|
||||
sessionId: SessionId = aSessionId(),
|
||||
) = EncryptedMessageContent.MegOlmV1(cipherText, deviceId, senderKey, sessionId)
|
||||
|
||||
fun anEncryptedOlmV1Message(
|
||||
senderId: UserId = aUserId(),
|
||||
cipherText: Map<Curve25519, EncryptedMessageContent.CipherTextInfo> = emptyMap(),
|
||||
senderKey: Curve25519 = aCurve25519(),
|
||||
) = EncryptedMessageContent.OlmV1(senderId, cipherText, senderKey)
|
||||
|
||||
fun aCipherTextInfo(
|
||||
body: CipherText = aCipherText(),
|
||||
type: Int = 1,
|
||||
) = EncryptedMessageContent.CipherTextInfo(body, type)
|
@ -0,0 +1,14 @@
|
||||
package fixture
|
||||
|
||||
import app.dapk.st.matrix.common.AlgorithmName
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.matrix.common.SessionId
|
||||
import app.dapk.st.matrix.common.SharedRoomKey
|
||||
|
||||
fun aSharedRoomKey(
|
||||
algorithmName: AlgorithmName = anAlgorithmName(),
|
||||
roomId: RoomId = aRoomId(),
|
||||
sessionId: SessionId = aSessionId(),
|
||||
sessionKey: String = "a-session-key",
|
||||
isExported: Boolean = false,
|
||||
) = SharedRoomKey(algorithmName, roomId, sessionId, sessionKey, isExported)
|
@ -9,7 +9,7 @@ import app.dapk.st.matrix.crypto.Olm
|
||||
|
||||
private val ALGORITHM_MEGOLM = AlgorithmName("m.megolm.v1.aes-sha2")
|
||||
|
||||
typealias EncryptMessageWithMegolmUseCase = suspend (DeviceCredentials, MessageToEncrypt) -> Crypto.EncryptionResult
|
||||
internal typealias EncryptMessageWithMegolmUseCase = suspend (DeviceCredentials, MessageToEncrypt) -> Crypto.EncryptionResult
|
||||
|
||||
internal class EncryptMessageWithMegolmUseCaseImpl(
|
||||
private val olm: Olm,
|
||||
|
@ -7,7 +7,7 @@ import app.dapk.st.matrix.common.crypto
|
||||
import app.dapk.st.matrix.crypto.Olm
|
||||
import app.dapk.st.matrix.device.DeviceService
|
||||
|
||||
typealias MaybeCreateAndUploadOneTimeKeysUseCase = suspend (ServerKeyCount) -> Unit
|
||||
internal typealias MaybeCreateAndUploadOneTimeKeysUseCase = suspend (ServerKeyCount) -> Unit
|
||||
|
||||
internal class MaybeCreateAndUploadOneTimeKeysUseCaseImpl(
|
||||
private val fetchAccountCryptoUseCase: FetchAccountCryptoUseCase,
|
||||
|
@ -6,7 +6,7 @@ import app.dapk.st.matrix.common.UserId
|
||||
import app.dapk.st.matrix.common.crypto
|
||||
import app.dapk.st.matrix.device.DeviceService
|
||||
|
||||
typealias UpdateKnownOlmSessionUseCase = suspend (List<UserId>, SyncToken?) -> Unit
|
||||
internal typealias UpdateKnownOlmSessionUseCase = suspend (List<UserId>, SyncToken?) -> Unit
|
||||
|
||||
internal class UpdateKnownOlmSessionUseCaseImpl(
|
||||
private val fetchAccountCryptoUseCase: FetchAccountCryptoUseCase,
|
||||
|
@ -4,13 +4,13 @@ import app.dapk.st.matrix.common.*
|
||||
import fake.FakeMatrixLogger
|
||||
import fake.FakeOlm
|
||||
import fixture.*
|
||||
import internalfake.FakeEncryptMessageWithMegolmUseCase
|
||||
import internalfake.FakeFetchAccountCryptoUseCase
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import internalfake.FakeMaybeCreateAndUploadOneTimeKeysUseCase
|
||||
import internalfake.FakeUpdateKnownOlmSessionUseCase
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import org.junit.Test
|
||||
import test.delegateReturn
|
||||
import test.runExpectTest
|
||||
|
||||
private val A_LIST_OF_SHARED_ROOM_KEYS = listOf(aSharedRoomKey())
|
||||
@ -27,7 +27,6 @@ private val AN_ACCOUNT_CRYPTO_SESSION = anAccountCryptoSession()
|
||||
private val AN_OLM_PAYLOAD = anEncryptedOlmV1Message(cipherText = mapOf(AN_ACCOUNT_CRYPTO_SESSION.senderKey to aCipherTextInfo()))
|
||||
private val A_DECRYPTION_RESULT = aDecryptionSuccessResult()
|
||||
|
||||
|
||||
internal class OlmCryptoTest {
|
||||
|
||||
private val fakeOlm = FakeOlm()
|
||||
@ -100,44 +99,3 @@ internal class OlmCryptoTest {
|
||||
result shouldBeEqualTo A_DECRYPTION_RESULT
|
||||
}
|
||||
}
|
||||
|
||||
fun aDecryptionSuccessResult(
|
||||
payload: JsonString = aJsonString(),
|
||||
isVerified: Boolean = false,
|
||||
) = DecryptionResult.Success(payload, isVerified)
|
||||
|
||||
fun anEncryptedMegOlmV1Message(
|
||||
cipherText: CipherText = aCipherText(),
|
||||
deviceId: DeviceId = aDeviceId(),
|
||||
senderKey: String = "a-sender-key",
|
||||
sessionId: SessionId = aSessionId(),
|
||||
) = EncryptedMessageContent.MegOlmV1(cipherText, deviceId, senderKey, sessionId)
|
||||
|
||||
fun anEncryptedOlmV1Message(
|
||||
senderId: UserId = aUserId(),
|
||||
cipherText: Map<Curve25519, EncryptedMessageContent.CipherTextInfo> = emptyMap(),
|
||||
senderKey: Curve25519 = aCurve25519(),
|
||||
) = EncryptedMessageContent.OlmV1(senderId, cipherText, senderKey)
|
||||
|
||||
fun aCipherTextInfo(
|
||||
body: CipherText = aCipherText(),
|
||||
type: Int = 1,
|
||||
) = EncryptedMessageContent.CipherTextInfo(body, type)
|
||||
|
||||
class FakeEncryptMessageWithMegolmUseCase : EncryptMessageWithMegolmUseCase by mockk() {
|
||||
fun givenEncrypt(credentials: DeviceCredentials, message: MessageToEncrypt) = coEvery {
|
||||
this@FakeEncryptMessageWithMegolmUseCase.invoke(credentials, message)
|
||||
}.delegateReturn()
|
||||
}
|
||||
|
||||
class FakeUpdateKnownOlmSessionUseCase : UpdateKnownOlmSessionUseCase by mockk()
|
||||
|
||||
class FakeMaybeCreateAndUploadOneTimeKeysUseCase : MaybeCreateAndUploadOneTimeKeysUseCase by mockk()
|
||||
|
||||
fun aSharedRoomKey(
|
||||
algorithmName: AlgorithmName = anAlgorithmName(),
|
||||
roomId: RoomId = aRoomId(),
|
||||
sessionId: SessionId = aSessionId(),
|
||||
sessionKey: String = "a-session-key",
|
||||
isExported: Boolean = false,
|
||||
) = SharedRoomKey(algorithmName, roomId, sessionId, sessionKey, isExported)
|
@ -0,0 +1,14 @@
|
||||
package internalfake
|
||||
|
||||
import app.dapk.st.matrix.common.DeviceCredentials
|
||||
import app.dapk.st.matrix.crypto.internal.EncryptMessageWithMegolmUseCase
|
||||
import app.dapk.st.matrix.crypto.internal.MessageToEncrypt
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import test.delegateReturn
|
||||
|
||||
class FakeEncryptMessageWithMegolmUseCase : EncryptMessageWithMegolmUseCase by mockk() {
|
||||
fun givenEncrypt(credentials: DeviceCredentials, message: MessageToEncrypt) = coEvery {
|
||||
this@FakeEncryptMessageWithMegolmUseCase.invoke(credentials, message)
|
||||
}.delegateReturn()
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package internalfake
|
||||
|
||||
import app.dapk.st.matrix.crypto.internal.MaybeCreateAndUploadOneTimeKeysUseCase
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeMaybeCreateAndUploadOneTimeKeysUseCase : MaybeCreateAndUploadOneTimeKeysUseCase by mockk()
|
@ -0,0 +1,6 @@
|
||||
package internalfake
|
||||
|
||||
import app.dapk.st.matrix.crypto.internal.UpdateKnownOlmSessionUseCase
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeUpdateKnownOlmSessionUseCase : UpdateKnownOlmSessionUseCase by mockk()
|
@ -10,6 +10,7 @@ import io.mockk.slot
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
import test.Returns
|
||||
import test.delegateReturn
|
||||
import test.returns
|
||||
|
||||
class FakeOlm : Olm by mockk() {
|
||||
|
||||
@ -22,7 +23,7 @@ class FakeOlm : Olm by mockk() {
|
||||
fun givenCreatesAccount(credentials: UserCredentials): Returns<Olm.AccountCryptoSession> {
|
||||
val slot = slot<suspend (Olm.AccountCryptoSession) -> Unit>()
|
||||
val mockKStubScope = coEvery { ensureAccountCrypto(credentials, capture(slot)) }
|
||||
return Returns { value ->
|
||||
return returns { value ->
|
||||
mockKStubScope coAnswers {
|
||||
slot.captured.invoke(value)
|
||||
value
|
||||
@ -39,7 +40,7 @@ class FakeOlm : Olm by mockk() {
|
||||
fun givenMissingOlmSessions(newDevices: List<DeviceKeys>): Returns<List<Olm.DeviceCryptoSession>> {
|
||||
val slot = slot<suspend (List<DeviceKeys>) -> List<Olm.DeviceCryptoSession>>()
|
||||
val mockKStubScope = coEvery { olmSessions(newDevices, capture(slot)) }
|
||||
return Returns { value ->
|
||||
return returns { value ->
|
||||
mockKStubScope coAnswers {
|
||||
slot.captured.invoke(newDevices).also {
|
||||
value shouldBeEqualTo it
|
||||
@ -55,7 +56,7 @@ class FakeOlm : Olm by mockk() {
|
||||
): Returns<DeviceService.OneTimeKeys> {
|
||||
val slot = slot<suspend (DeviceService.OneTimeKeys) -> Unit>()
|
||||
val mockKStubScope = coEvery { with(accountCryptoSession) { generateOneTimeKeys(countToCreate, credentials, capture(slot)) } }
|
||||
return Returns { value ->
|
||||
return returns { value ->
|
||||
mockKStubScope coAnswers {
|
||||
slot.captured.invoke(value)
|
||||
}
|
||||
|
@ -25,6 +25,8 @@ include ':domains:android:imageloader'
|
||||
include ':domains:android:work'
|
||||
include ':domains:android:tracking'
|
||||
include ':domains:android:push'
|
||||
include ':domains:android:viewmodel-stub'
|
||||
include ':domains:android:viewmodel'
|
||||
include ':domains:store'
|
||||
include ':domains:olm-stub'
|
||||
include ':domains:olm'
|
||||
|
@ -84,7 +84,7 @@ class TestMatrix(
|
||||
val olmAccountStore = OlmPersistenceWrapper(storeModule.olmStore())
|
||||
val olm = OlmWrapper(
|
||||
olmStore = olmAccountStore,
|
||||
singletonFlows = SingletonFlows(),
|
||||
singletonFlows = SingletonFlows(coroutineDispatchers),
|
||||
jsonCanonicalizer = JsonCanonicalizer(),
|
||||
deviceKeyFactory = DeviceKeyFactory(JsonCanonicalizer()),
|
||||
errorTracker = errorTracker,
|
||||
|
Loading…
x
Reference in New Issue
Block a user