move to roboswag modules (mvi, pagination, cicerone, viewbinding) from test proj

This commit is contained in:
Maxim Bachinsky 2020-06-27 18:42:58 +03:00
parent 5f39d04f46
commit d565637407
45 changed files with 1032 additions and 289 deletions

View File

@ -4,9 +4,9 @@ Roboswag - библиотека решений, ускоряющих разра
## Минимальные требования
* Andoroid Api: 19
* Android Api: 21
* Kotlin: 1.3.11
* Gradle: 3.2.1
* Gradle: 4.0.0
## Основная архитектура
За основу архитектуры взят подход от Google - MVVM на основе [Android Architecture Components](https://developer.android.com/jetpack/docs/guide). Данный подход популярен в сообществе Android разработки, позволяет разбивать код на мелкие и независимые части, что ускоряет разработку и последующую поддержку приложения.

View File

@ -1,14 +0,0 @@
Encrypted shared preferences
============================
Модуль с реализацией интерфейса `SharedPreferences`, который дает возможность шифровать содержимое.
### Пример
Пример получения экземпляра `TouchinSharedPreferences`. При encrypt = false, `TouchinSharedPreferences` абсолютно аналогичны стандартной реализации `SharedPreferences`
```kotlin
val prefs = TouchinSharedPreferences(name = "APPLICATION_DATA_ENCRYPTED", context = context, encrypt = true)
```
Важно помнить, что в одном файле `TouchinSharedPreferences` могут храниться только либо полностью зашифрованные данные, либо полностью незашифрованные. Флаг `isEncryption` должен быть в соответствующем положении

View File

@ -1,24 +0,0 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion versions.compileSdk
defaultConfig {
minSdkVersion 21
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.core:core:$versions.androidx"
implementation "androidx.annotation:annotation:$versions.androidx"
implementation "androidx.appcompat:appcompat:$versions.appcompat"
}

View File

@ -1 +0,0 @@
<manifest package="ru.touchin.roboswag.sharedprefs" />

View File

@ -1,113 +0,0 @@
package ru.touchin.roboswag
import android.content.Context
import android.content.SharedPreferences
import ru.touchin.roboswag.PrefsCryptoUtils.Companion.ENCRYPT_BASE64_STRING_LENGTH
import ru.touchin.roboswag.PrefsCryptoUtils.Companion.ENCRYPT_BLOCK_SIZE
class CipherSharedPreferences(name: String, context: Context, val encrypt: Boolean = false) : SharedPreferences {
private val currentPreferences: SharedPreferences = context.getSharedPreferences(name, Context.MODE_PRIVATE)
private val cryptoUtils = PrefsCryptoUtils(context)
override fun contains(key: String?) = currentPreferences.contains(key)
override fun getBoolean(key: String?, defaultValue: Boolean) = get(key, defaultValue)
override fun unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener?) {
currentPreferences.unregisterOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener)
}
override fun getInt(key: String?, defaultValue: Int) = get(key, defaultValue)
override fun getAll(): MutableMap<String, String> {
return if (encrypt) {
currentPreferences.all.mapValues { it.value.toString().decrypt() }.toMutableMap()
} else {
currentPreferences.all.mapValues { it.value.toString() }.toMutableMap()
}
}
override fun edit() = TouchinEditor()
override fun getLong(key: String?, defaultValue: Long) = get(key, defaultValue)
override fun getFloat(key: String?, defaultValue: Float) = get(key, defaultValue)
override fun getString(key: String?, defaultValue: String?): String = get(key, defaultValue ?: "")
override fun getStringSet(key: String?, set: MutableSet<String>?): MutableSet<String>? {
return if (encrypt) {
val value = currentPreferences.getStringSet(key, set)
if (value == set) {
set
} else {
value?.map { it.decrypt() }?.toMutableSet()
}
} else {
currentPreferences.getStringSet(key, set)
}
}
override fun registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener?) {
currentPreferences.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener)
}
private fun <T> get(key: String?, defaultValue: T): T {
if (!currentPreferences.contains(key)) return defaultValue
val resultValue = currentPreferences.getString(key, "")
?.trim()
?.chunked(ENCRYPT_BASE64_STRING_LENGTH)
?.joinToString(separator = "", transform = { it.decrypt() })
return when (defaultValue) {
is Boolean -> resultValue?.toBoolean() as? T
is Long -> resultValue?.toLong() as? T
is String -> resultValue as? T
is Int -> resultValue?.toInt() as? T
is Float -> resultValue?.toFloat() as? T
else -> resultValue as? T
} ?: defaultValue
}
private fun String.decrypt() = if (encrypt) cryptoUtils.decrypt(this) else this
inner class TouchinEditor : SharedPreferences.Editor {
override fun clear() = currentPreferences.edit().clear()
override fun putLong(key: String?, value: Long) = put(key, value)
override fun putInt(key: String?, value: Int) = put(key, value)
override fun remove(key: String?) = currentPreferences.edit().remove(key)
override fun putBoolean(key: String?, value: Boolean) = put(key, value)
override fun putStringSet(key: String?, value: MutableSet<String>?): SharedPreferences.Editor {
return if (encrypt) {
currentPreferences.edit().putStringSet(key, value?.map { it.encrypt() }?.toMutableSet())
} else {
currentPreferences.edit().putStringSet(key, value)
}
}
override fun commit() = currentPreferences.edit().commit()
override fun putFloat(key: String?, value: Float) = put(key, value)
override fun apply() = currentPreferences.edit().apply()
override fun putString(key: String?, value: String?) = put(key, value)
private fun <T> put(key: String?, value: T): SharedPreferences.Editor {
val resultValue = value?.toString()?.chunked(ENCRYPT_BLOCK_SIZE)?.joinToString(separator = "", transform = { it.encrypt() })
return currentPreferences.edit().putString(key, resultValue)
}
private fun String.encrypt() = if (encrypt) cryptoUtils.encrypt(this) else this
}
}

View File

@ -1,10 +0,0 @@
package ru.touchin.roboswag
import android.content.SharedPreferences
fun CipherSharedPreferences.migrateFromSharedPreferences(from: SharedPreferences, key: String): SharedPreferences {
if (!from.contains(key)) return this
edit().putString(key, from.getString(key, "") ?: "").apply()
from.edit().remove(key).apply()
return this
}

View File

@ -1,125 +0,0 @@
package ru.touchin.roboswag
import android.annotation.TargetApi
import android.content.Context
import android.os.Build
import android.security.KeyPairGeneratorSpec
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.math.BigInteger
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.util.Calendar
import javax.crypto.Cipher
import javax.security.auth.x500.X500Principal
// https://proandroiddev.com/secure-data-in-android-encryption-in-android-part-2-991a89e55a23
@Suppress("detekt.TooGenericExceptionCaught")
class PrefsCryptoUtils constructor(val context: Context) {
companion object {
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_ALGORITHM_RSA = "RSA"
private const val TRANSFORMATION_ASYMMETRIC = "RSA/ECB/PKCS1Padding"
private const val CIPHER_STRING_SIZE_BYTES = 256
private const val BASE_64_PADDING = 2
private const val STORAGE_KEY = "STORAGE_KEY"
//https://stackoverflow.com/questions/13378815/base64-length-calculation
private const val DECRYPTED_BYTES_COUNT = 3
private const val ENCRYPTED_BYTES_COUNT = 4
private const val BASE64_DIVIDER_COUNT = 5
const val ENCRYPT_BASE64_STRING_LENGTH =
(CIPHER_STRING_SIZE_BYTES + BASE_64_PADDING) * ENCRYPTED_BYTES_COUNT / DECRYPTED_BYTES_COUNT + BASE64_DIVIDER_COUNT
const val ENCRYPT_BLOCK_SIZE = 128
private fun getAndroidKeystore(): KeyStore? = try {
KeyStore.getInstance(ANDROID_KEY_STORE).also { it.load(null) }
} catch (exception: Exception) {
null
}
private fun getAndroidKeyStoreAsymmetricKeyPair(): KeyPair? {
val privateKey = getAndroidKeystore()?.getKey(STORAGE_KEY, null) as PrivateKey?
val publicKey = getAndroidKeystore()?.getCertificate(STORAGE_KEY)?.publicKey
return if (privateKey != null && publicKey != null) {
KeyPair(publicKey, privateKey)
} else {
null
}
}
private fun createAndroidKeyStoreAsymmetricKey(context: Context): KeyPair {
val generator = KeyPairGenerator.getInstance(KEY_ALGORITHM_RSA, ANDROID_KEY_STORE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
initGeneratorWithKeyPairGeneratorSpec(generator)
} else {
initGeneratorWithKeyGenParameterSpec(generator, context)
}
// Generates Key with given spec and saves it to the KeyStore
return generator.generateKeyPair()
}
private fun initGeneratorWithKeyGenParameterSpec(generator: KeyPairGenerator, context: Context) {
val startDate = Calendar.getInstance()
val endDate = Calendar.getInstance()
endDate.add(Calendar.YEAR, 20)
val builder = KeyPairGeneratorSpec.Builder(context)
.setAlias(STORAGE_KEY)
.setSerialNumber(BigInteger.ONE)
.setSubject(X500Principal("CN=$STORAGE_KEY CA Certificate"))
.setStartDate(startDate.time)
.setEndDate(endDate.time)
generator.initialize(builder.build())
}
@TargetApi(Build.VERSION_CODES.M)
private fun initGeneratorWithKeyPairGeneratorSpec(generator: KeyPairGenerator) {
val builder = KeyGenParameterSpec.Builder(STORAGE_KEY, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_ECB)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
generator.initialize(builder.build())
}
private fun createCipher(): Cipher? = try {
Cipher.getInstance(TRANSFORMATION_ASYMMETRIC)
} catch (exception: Exception) {
null
}
}
private val cipher = createCipher()
private val keyPair = getAndroidKeyStoreAsymmetricKeyPair()
?: createAndroidKeyStoreAsymmetricKey(context)
// Those methods should not take and return strings, only char[] and those arrays should be cleared right after usage
// See for explanation https://docs.oracle.com/javase/6/docs/technotes/guides/security/crypto/CryptoSpec.html#PBEEx
@Synchronized
fun encrypt(data: String): String {
cipher?.init(Cipher.ENCRYPT_MODE, keyPair.public)
val bytes = cipher?.doFinal(data.toByteArray())
return Base64.encodeToString(bytes, Base64.DEFAULT)
}
@Synchronized
fun decrypt(data: String?): String {
cipher?.init(Cipher.DECRYPT_MODE, keyPair.private)
if (data.isNullOrBlank()) {
return String()
}
val encryptedData = Base64.decode(data, Base64.DEFAULT)
val decodedData = cipher?.doFinal(encryptedData)
return decodedData?.let { decodedData -> String(decodedData) } ?: ""
}
}

4
mvi-arch/README.md Normal file
View File

@ -0,0 +1,4 @@
mvi_arch
====
TODO: rewrite dependencies

13
mvi-arch/build.gradle.kts Normal file
View File

@ -0,0 +1,13 @@
plugins {
id(Plugins.ANDROID_LIB_PLUGIN_WITH_DEFAULT_CONFIG)
}
dependencies {
androidX()
fragment()
lifecycle()
dagger()
coroutines()
}

View File

@ -0,0 +1 @@
<manifest package="ru.touchin.mvi_arch" />

View File

@ -0,0 +1,150 @@
package ru.touchin.roboswag.mvi_arch.core
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.annotation.MainThread
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import ru.touchin.extensions.setOnRippleClickListener
import ru.touchin.roboswag.mvi_arch.di.ViewModelAssistedFactory
import ru.touchin.roboswag.mvi_arch.di.ViewModelFactory
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
import ru.touchin.roboswag.mvi_arch.marker.ViewState
import ru.touchin.roboswag.navigation_base.fragments.BaseFragment
import ru.touchin.roboswag.navigation_base.fragments.EmptyState
import javax.inject.Inject
/**
* Base [Fragment] to use in MVI architecture.
*
* @param NavArgs Type of arguments class of this screen.
* It must implement [NavArgs] interface provided by navigation library that is a part of Google Jetpack.
* An instance of this class is generated by [SafeArgs](https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args)
* plugin according to related configuration file in navigation resource folder of your project.
*
* @param State Type of view state class of this screen.
* It must implement [ViewState] interface. Usually it's a data class that presents full state of current screen's view.
* @see [ViewState] for more information.
*
* @param Action Type of view actions class of this screen.
* It must implement [Action] interface. Usually it's a sealed class that contains classes and objects representing
* view actions of this view, e.g. button clicks, text changes, etc.
* @see [Action] for more information.
*
* @param VM Type of view model class of this screen.
* It must extends [MviViewModel] class with the same params.
* @see [MviViewModel] for more information.
*
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
*/
abstract class MviFragment<NavArgs, State, Action, VM>(
@LayoutRes layout: Int,
navArgs: NavArgs = EmptyState as NavArgs
) : BaseFragment<FragmentActivity>(layout)
where NavArgs : Parcelable,
State : ViewState,
Action : ViewAction,
VM : MviViewModel<NavArgs, Action, State> {
companion object {
const val INIT_ARGS_KEY = "INIT_ARGS"
}
/**
* Use [viewModel] extension to get an instance of your view model class.
*/
protected abstract val viewModel: VM
/**
* Used for smooth view model injection to this class.
*/
@Inject
lateinit var viewModelMap: MutableMap<Class<out ViewModel>, ViewModelAssistedFactory<out ViewModel>>
init {
arguments?.putParcelable(INIT_ARGS_KEY, navArgs) ?: let {
arguments = bundleOf(INIT_ARGS_KEY to navArgs)
}
}
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.state.observe(viewLifecycleOwner, Observer(this::renderState))
}
/**
* Use this method to subscribe on view state changes.
*
* You should render view state here.
*
* Must not be called before [onAttach] and after [onDetach].
*/
protected open fun renderState(viewState: State) {}
/**
* Use this method to dispatch view actions to view model.
*/
protected fun dispatchAction(actionProvider: () -> Action) {
viewModel.dispatchAction(actionProvider.invoke())
}
/**
* Use this method to dispatch view actions to view model.
*/
protected fun dispatchAction(action: Action) {
viewModel.dispatchAction(action)
}
/**
* Lazily provides view model of this screen with transmitted arguments if exist.
*
* Value of this lazily providing must not be accessed before [onAttach] and after [onDetach].
*/
@MainThread
protected inline fun <reified ViewModel : VM> viewModel(): Lazy<ViewModel> =
lazy {
val fragmentArguments = arguments ?: bundleOf()
ViewModelProvider(
viewModelStore,
ViewModelFactory(viewModelMap, this, fragmentArguments)
).get(ViewModel::class.java)
}
/**
* Simple extension for dispatching view events to view model with on click.
*/
protected fun View.dispatchActionOnClick(actionProvider: () -> Action) {
setOnClickListener { dispatchAction(actionProvider) }
}
/**
* Simple extension for dispatching view events to view model with on click.
*/
protected fun View.dispatchActionOnClick(action: Action) {
setOnClickListener { dispatchAction(action) }
}
/**
* Simple extension for dispatching view events to view model with on ripple click.
*/
protected fun View.dispatchActionOnRippleClick(actionProvider: () -> Action) {
setOnRippleClickListener { dispatchAction(actionProvider) }
}
/**
* Simple extension for dispatching view events to view model with on ripple click.
*/
protected fun View.dispatchActionOnRippleClick(action: Action) {
setOnRippleClickListener { dispatchAction(action) }
}
}

View File

@ -0,0 +1,82 @@
package ru.touchin.roboswag.mvi_arch.core
import android.os.Parcelable
import androidx.annotation.CallSuper
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import ru.touchin.roboswag.mvi_arch.marker.SideEffect
import ru.touchin.roboswag.mvi_arch.marker.StateChange
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
import ru.touchin.roboswag.mvi_arch.marker.ViewState
/**
* Base [ViewModel] to use in MVI architecture.
*
* @param NavArgs Type of arguments class of this screen.
* It must implement [NavArgs] interface provided by navigation library that is a part of Google Jetpack.
* An instance of this class is generated by [SafeArgs](https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args)
* plugin according to related configuration file in navigation resource folder of your project.
*
* @param State Type of view state class of this screen.
* It must implement [ViewState] interface. Usually it's a data class that presents full state of current screen's view.
* @see [ViewState] for more information.
*
* @param Action Type of view actions class of this screen.
* It must implement [Action] interface. Usually it's a sealed class that contains classes and objects representing
* view actions of this view, e.g. button clicks, text changes, etc.
* @see [Action] for more information.
*
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
*/
abstract class MviStoreViewModel<NavArgs : Parcelable, Action : ViewAction, State : ViewState>(
initialState: State,
handle: SavedStateHandle
) : MviViewModel<NavArgs, Action, State>(initialState, handle) {
private lateinit var store: ChildStore<*, *, *>
protected fun <IChange : StateChange, IEffect : SideEffect, IState : ViewState> connectStore(
store: Store<IChange, IEffect, IState>,
mapAction: (Action) -> IChange?,
mapState: (IState) -> State
) {
this.store = ChildStore(store, mapAction)
store
.observeState()
.map { mapState(it) }
.onEach { this._state.postValue(it) }
.launchIn(viewModelScope)
}
@CallSuper
override fun dispatchAction(action: Action) {
store.dispatchAction(action)
}
@CallSuper
override fun onCleared() {
super.onCleared()
store.onCleared()
}
private inner class ChildStore<IChange : StateChange, IEffect : SideEffect, IState : ViewState>(
val store: Store<IChange, IEffect, IState>,
val changeMapper: (Action) -> IChange?
) {
fun onCleared() {
store.onCleared()
}
fun dispatchAction(action: Action) {
changeMapper(action)?.let(store::changeState)
}
}
}

View File

@ -0,0 +1,46 @@
package ru.touchin.roboswag.mvi_arch.core
import android.os.Parcelable
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import ru.touchin.roboswag.mvi_arch.marker.ViewAction
import ru.touchin.roboswag.mvi_arch.marker.ViewState
/**
* Base [ViewModel] to use in MVI architecture.
*
* @param NavArgs Type of arguments class of this screen.
* It must implement [NavArgs] interface provided by navigation library that is a part of Google Jetpack.
* An instance of this class is generated by [SafeArgs](https://developer.android.com/guide/navigation/navigation-pass-data#Safe-args)
* plugin according to related configuration file in navigation resource folder of your project.
*
* @param State Type of view state class of this screen.
* It must implement [ViewState] interface. Usually it's a data class that presents full state of current screen's view.
* @see [ViewState] for more information.
*
* @param Action Type of view actions class of this screen.
* It must implement [Action] interface. Usually it's a sealed class that contains classes and objects representing
* view actions of this view, e.g. button clicks, text changes, etc.
* @see [Action] for more information.
*
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
*/
abstract class MviViewModel<NavArgs : Parcelable, Action : ViewAction, State : ViewState>(
private val initialState: State,
protected val handle: SavedStateHandle
) : ViewModel() {
protected val navArgs: NavArgs = handle.get(MviFragment.INIT_ARGS_KEY) ?: throw IllegalStateException("Nav args mustn't be null")
protected val _state = MutableLiveData(initialState)
internal val state = Transformations.distinctUntilChanged(_state)
protected val currentState: State
get() = state.value ?: initialState
abstract fun dispatchAction(action: Action)
}

View File

@ -0,0 +1,106 @@
package ru.touchin.roboswag.mvi_arch.core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import ru.touchin.roboswag.mvi_arch.marker.SideEffect
import ru.touchin.roboswag.mvi_arch.marker.StateChange
import ru.touchin.roboswag.mvi_arch.marker.ViewState
abstract class Store<Change : StateChange, Effect : SideEffect, State : ViewState>(
initialState: State
) {
protected val currentState: State
get() = state.value
private val storeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val effects = Channel<Effect?>(Channel.UNLIMITED)
private val state = MutableStateFlow(initialState)
private val childStores: MutableList<ChildStore<*, *, *>> = mutableListOf()
init {
storeScope.launch {
effects
.consumeAsFlow()
.filterNotNull()
.handleSideEffect()
.collect { newChange -> changeState(newChange) }
}
}
fun changeState(change: Change) {
val (newState, newEffect) = reduce(currentState, change)
if (currentState != newState) {
state.value = newState
}
childStores.forEach { childStore ->
childStore.change(change)
}
newEffect?.let {
effects.offer(it)
}
}
fun observeState(): Flow<State> = state
fun onCleared() {
storeScope.coroutineContext.cancel()
childStores.forEach(Store<Change, Effect, State>.ChildStore<*, *, *>::onCleared)
}
fun State.only(): Pair<State, Effect?> = this to null
fun Effect.only(): Pair<State, Effect> = currentState to this
fun same(): Pair<State, Effect?> = currentState.only()
protected fun <ChildChange : StateChange, ChildEffect : SideEffect, ChildState : ViewState> addChildStore(
store: Store<ChildChange, ChildEffect, ChildState>,
changeMapper: (Change) -> ChildChange?,
stateMapper: (ChildState) -> State
) {
childStores.add(ChildStore(store, changeMapper))
store
.observeState()
.onEach { state.value = stateMapper(it) }
.launchIn(storeScope)
}
protected open fun Flow<Effect>.handleSideEffect(): Flow<Change> = emptyFlow()
protected abstract fun reduce(currentState: State, change: Change): Pair<State, Effect?>
private inner class ChildStore<ChildChange : StateChange, ChildEffect : SideEffect, ChildState : ViewState>(
val store: Store<ChildChange, ChildEffect, ChildState>,
val changeMapper: (Change) -> ChildChange?
) {
fun onCleared() {
store.onCleared()
}
fun change(change: Change) {
changeMapper(change)?.let(store::changeState)
}
}
}

View File

@ -0,0 +1,8 @@
package ru.touchin.roboswag.mvi_arch.di
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
interface ViewModelAssistedFactory<VM : ViewModel> {
fun create(handle: SavedStateHandle): VM
}

View File

@ -0,0 +1,19 @@
package ru.touchin.roboswag.mvi_arch.di
import android.os.Bundle
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
class ViewModelFactory(
private val viewModelMap: MutableMap<Class<out ViewModel>, ViewModelAssistedFactory<out ViewModel>>,
owner: SavedStateRegistryOwner,
arguments: Bundle
) : AbstractSavedStateViewModelFactory(owner, arguments) {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
return viewModelMap[modelClass]?.create(handle) as? T ?: throw IllegalStateException("Unknown ViewModel class")
}
}

View File

@ -0,0 +1,9 @@
package ru.touchin.roboswag.mvi_arch.di
import androidx.lifecycle.ViewModel
import dagger.MapKey
import kotlin.reflect.KClass
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER)
@MapKey
annotation class ViewModelKey(val value: KClass<out ViewModel>)

View File

@ -0,0 +1,3 @@
package ru.touchin.roboswag.mvi_arch.marker
interface SideEffect

View File

@ -0,0 +1,4 @@
package ru.touchin.roboswag.mvi_arch.marker
interface StateChange {
}

View File

@ -0,0 +1,22 @@
package ru.touchin.roboswag.mvi_arch.marker
/**
* This interface should be implemented to create your own view actions and use it with [MviFragment] and [MviViewModel].
*
* Usually it's sealed class with nested classes and objects representing view actions.
*
* Quite common cases:
* 1. View contains simple button:
* object OnButtonClicked : YourViewAction()
*
* 2. View contains button with parameter:
* data class OnButtonWithParamClicked(val param: Param): YourViewAction()
*
* 3. View contains text input field:
* data class OnInputChanged(val input: String): YourViewAction()
*
* Exemplars of this classes used to generate new [ViewState] in [MviViewModel].
*
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
*/
interface ViewAction

View File

@ -0,0 +1,12 @@
package ru.touchin.roboswag.mvi_arch.marker
/**
* This interface should be implemented to create your own view state and use it with [MviFragment] and [MviViewModel].
*
* Usually it's a data class that presents full state of view.
*
* You should not use mutable values here. All values should be immutable.
*
* @author Created by Max Bachinsky and Ivan Vlasov at Touch Instinct.
*/
interface ViewState

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">mvi-arch</string>
</resources>

View File

@ -2,6 +2,10 @@ apply from: "../android-configs/lib-config.gradle"
apply plugin: 'kotlin-kapt'
android {
buildFeatures.viewBinding = true
}
dependencies {
implementation project(":utils")
implementation project(":logging")
@ -17,6 +21,9 @@ dependencies {
implementation "androidx.fragment:fragment"
implementation "androidx.fragment:fragment-ktx"
implementation "androidx.lifecycle:lifecycle-common-java8"
implementation "androidx.lifecycle:lifecycle-livedata-ktx"
implementation("com.crashlytics.sdk.android:crashlytics") {
transitive = true
}
@ -57,5 +64,17 @@ dependencies {
require '2.10.0'
}
}
implementation("androidx.lifecycle:lifecycle-common-java8") {
version {
require '2.2.0'
}
}
implementation("androidx.lifecycle:lifecycle-livedata-ktx") {
version {
require '2.2.0'
}
}
}
}

View File

@ -0,0 +1,49 @@
package ru.touchin.roboswag.navigation_base.fragments
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.observe
import androidx.viewbinding.ViewBinding
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
class FragmentViewBindingDelegate<T : ViewBinding>(
val fragment: Fragment,
val viewBindingFactory: (View) -> T
) : ReadOnlyProperty<Fragment, T> {
private var binding: T? = null
init {
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
binding = null
}
})
}
}
})
}
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
val binding = binding
if (binding != null) {
return binding
}
val lifecycle = fragment.viewLifecycleOwner.lifecycle
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
}
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
}
}
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
FragmentViewBindingDelegate(this, viewBindingFactory)

View File

@ -0,0 +1,4 @@
navigation-cicerone
====
TODO: rewrite dependencies

View File

@ -0,0 +1,10 @@
plugins {
id(Plugins.ANDROID_LIB_PLUGIN_WITH_DEFAULT_CONFIG)
}
dependencies {
implementationModule(Module.Core.DI)
implementation(Library.CICERONE)
fragment()
dagger(withAssistedInject = false)
}

View File

@ -0,0 +1 @@
<manifest package="ru.touchin.mvi_arch.core_nav" />

View File

@ -0,0 +1,44 @@
package ru.touchin.roboswag.navigation_cicerone
import androidx.annotation.IdRes
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import ru.terrakok.cicerone.NavigatorHolder
import ru.terrakok.cicerone.android.support.SupportAppNavigator
class CiceroneTuner(
private val activity: FragmentActivity,
private val navigatorHolder: NavigatorHolder,
@IdRes private val fragmentContainerId: Int,
private val fragmentManager: FragmentManager? = null
) : LifecycleObserver {
val navigator by lazy(this::createNavigator)
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun addNavigator() {
navigatorHolder.setNavigator(navigator)
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun removeNavigator() {
navigatorHolder.removeNavigator()
}
private fun createNavigator() = if (fragmentManager != null) {
SupportAppNavigator(
activity,
fragmentManager,
fragmentContainerId
)
} else {
SupportAppNavigator(
activity,
fragmentContainerId
)
}
}

View File

@ -0,0 +1,46 @@
package ru.touchin.roboswag.navigation_cicerone.flow
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import ru.terrakok.cicerone.NavigatorHolder
import ru.terrakok.cicerone.Router
import ru.terrakok.cicerone.android.support.SupportAppScreen
import ru.touchin.mvi_arch.core_nav.R
import ru.touchin.roboswag.navigation_cicerone.CiceroneTuner
import javax.inject.Inject
abstract class FlowFragment : Fragment(R.layout.fragment_flow) {
@Inject
@FlowNavigation
lateinit var navigatorHolder: NavigatorHolder
@Inject
@FlowNavigation
lateinit var router: Router
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
injectComponent()
if (childFragmentManager.fragments.isEmpty()) {
router.newRootScreen(getLaunchScreen())
}
}
abstract fun injectComponent()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycle.addObserver(
CiceroneTuner(
activity = requireActivity(),
navigatorHolder = navigatorHolder,
fragmentContainerId = R.id.flow_parent,
fragmentManager = childFragmentManager
)
)
}
abstract fun getLaunchScreen(): SupportAppScreen
}

View File

@ -0,0 +1,6 @@
package ru.touchin.roboswag.navigation_cicerone.flow
import javax.inject.Qualifier
@Qualifier
annotation class FlowNavigation

View File

@ -0,0 +1,26 @@
package ru.touchin.roboswag.navigation_cicerone.flow
import dagger.Module
import dagger.Provides
import ru.terrakok.cicerone.Cicerone
import ru.terrakok.cicerone.NavigatorHolder
import ru.terrakok.cicerone.Router
import ru.touchin.mvi_arch.di.FeatureScope
@Module
class FlowNavigationModule {
@Provides
@FlowNavigation
@FeatureScope
fun provideCicerone(): Cicerone<Router> = Cicerone.create()
@Provides
@FlowNavigation
fun provideNavigatorHolder(@FlowNavigation cicerone: Cicerone<Router>): NavigatorHolder = cicerone.navigatorHolder
@Provides
@FlowNavigation
fun provideRouter(@FlowNavigation cicerone: Cicerone<Router>): Router = cicerone.router
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/flow_parent"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">mvi-arch</string>
</resources>

4
pagination/README.md Normal file
View File

@ -0,0 +1,4 @@
pagination
====
TODO: rewrite dependencies

View File

@ -0,0 +1,12 @@
plugins {
id(Plugins.ANDROID_LIB_PLUGIN_WITH_DEFAULT_CONFIG)
}
dependencies {
mvi()
materialDesign()
recyclerView()
implementationModule(Module.RoboSwag.KOTLIN_EXTENSIONS)
implementationModule(Module.RoboSwag.VIEWS)
implementationModule(Module.RoboSwag.UTILS)
}

View File

@ -0,0 +1 @@
<manifest package="ru.touchin.mvi_arch.core_pagination" />

View File

@ -0,0 +1,39 @@
package ru.touchin.roboswag.pagination
import android.annotation.SuppressLint
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import ru.touchin.adapters.AdapterDelegate
import ru.touchin.adapters.DelegationListAdapter
import ru.touchin.mvi_test.core_ui.pagination.ProgressItem
class PaginationAdapter(
private val nextPageCallback: () -> Unit,
private val itemIdDiff: (old: Any, new: Any) -> Boolean,
vararg delegate: AdapterDelegate<out RecyclerView.ViewHolder>
) : DelegationListAdapter<Any>(
object : DiffUtil.ItemCallback<Any>() {
override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean = itemIdDiff(oldItem, newItem)
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean = oldItem == newItem
}
) {
internal var fullData = false
init {
addDelegate(ProgressAdapterDelegate())
delegate.forEach(this::addDelegate)
}
fun update(data: List<Any>, isPageProgress: Boolean) {
submitList(data + listOfNotNull(ProgressItem.takeIf { isPageProgress }))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any>) {
super.onBindViewHolder(holder, position, payloads)
if (!fullData && position >= itemCount - 10) nextPageCallback.invoke()
}
}

View File

@ -0,0 +1,76 @@
package ru.touchin.roboswag.pagination
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import ru.touchin.extensions.setOnRippleClickListener
import ru.touchin.mvi_arch.core_pagination.databinding.ViewPaginationBinding
import ru.touchin.mvi_test.core_ui.pagination.Paginator
// TODO: add an errorview with empty state and error text
class PaginationView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private lateinit var refreshCallback: (() -> Unit)
private lateinit var adapter: PaginationAdapter
private val binding = ViewPaginationBinding.inflate(LayoutInflater.from(context), this, true)
init {
with(binding) {
swipeToRefresh.setOnRefreshListener { refreshCallback() }
elementsRecycler.layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
emptyText.setOnRippleClickListener { refreshCallback() }
}
}
fun init(refreshCallback: () -> Unit, adapter: PaginationAdapter) {
this.refreshCallback = refreshCallback
this.adapter = adapter
binding.elementsRecycler.adapter = adapter
}
fun render(state: Paginator.State) {
with(binding) {
swipeToRefresh.isRefreshing = state is Paginator.State.Refresh<*>
swipeToRefresh.isEnabled = state !is Paginator.State.EmptyProgress
switcher.showChild(
when (state) {
Paginator.State.Empty, is Paginator.State.EmptyError -> emptyText.id
Paginator.State.EmptyProgress -> progressBar.id
else -> elementsRecycler.id
}
)
adapter.fullData = state === Paginator.State.Empty || state is Paginator.State.FullData<*>
when (state) {
is Paginator.State.Empty -> {
adapter.update(emptyList(), false)
}
is Paginator.State.EmptyProgress -> {
adapter.update(emptyList(), false)
}
is Paginator.State.EmptyError -> {
adapter.update(emptyList(), false)
}
is Paginator.State.Data<*> -> {
adapter.update(state.data as List<Any>, false)
}
is Paginator.State.Refresh<*> -> {
adapter.update(state.data as List<Any>, false)
}
is Paginator.State.NewPageProgress<*> -> {
adapter.update(state.data as List<Any>, true)
}
is Paginator.State.FullData<*> -> {
adapter.update(state.data as List<Any>, false)
}
}
}
}
}

View File

@ -0,0 +1,128 @@
package ru.touchin.mvi_test.core_ui.pagination
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import ru.touchin.roboswag.mvi_arch.core.Store
import ru.touchin.roboswag.mvi_arch.marker.SideEffect
import ru.touchin.roboswag.mvi_arch.marker.StateChange
import ru.touchin.roboswag.mvi_arch.marker.ViewState
class Paginator<Item>(
private val showError: (Error) -> Unit,
private val loadPage: suspend (Int) -> List<Item>
) : Store<Paginator.Change, Paginator.Effect, Paginator.State>(State.Empty) {
sealed class Change : StateChange {
object Refresh : Change()
object Restart : Change()
object LoadMore : Change()
object Reset : Change()
data class NewPageLoaded<T>(val pageNumber: Int, val items: List<T>) : Change()
data class PageLoadError(val error: Throwable) : Change()
}
sealed class Effect : SideEffect {
data class LoadPage(val page: Int = 0) : Effect()
}
sealed class State : ViewState {
object Empty : State()
object EmptyProgress : State()
data class EmptyError(val error: Throwable) : State()
data class Data<T>(val pageCount: Int = 0, val data: List<T>) : State()
data class Refresh<T>(val pageCount: Int, val data: List<T>) : State()
data class NewPageProgress<T>(val pageCount: Int, val data: List<T>) : State()
data class FullData<T>(val pageCount: Int, val data: List<T>) : State()
}
sealed class Error {
object NewPageFailed : Error()
object RefreshFailed : Error()
}
override fun reduce(currentState: State, change: Change): Pair<State, Effect?> = when (change) {
Change.Refresh -> {
when (currentState) {
State.Empty -> State.EmptyProgress
is State.EmptyError -> State.EmptyProgress
is State.Data<*> -> State.Refresh(currentState.pageCount, currentState.data)
is State.NewPageProgress<*> -> State.Refresh(currentState.pageCount, currentState.data)
is State.FullData<*> -> State.Refresh(currentState.pageCount, currentState.data)
else -> currentState
} to Effect.LoadPage()
}
Change.Restart -> {
State.EmptyProgress to Effect.LoadPage()
}
Change.LoadMore -> {
when (currentState) {
is State.Data<*> -> {
State.NewPageProgress(currentState.pageCount, currentState.data) to Effect.LoadPage(currentState.pageCount + 1)
}
else -> currentState.only()
}
}
Change.Reset -> {
State.Empty.only()
}
is Change.NewPageLoaded<*> -> {
val items = change.items
when (currentState) {
is State.EmptyProgress -> {
if (items.isEmpty()) {
State.Empty
} else {
State.Data(0, items)
}
}
is State.Refresh<*> -> {
if (items.isEmpty()) {
State.Empty
} else {
State.Data(0, items)
}
}
is State.NewPageProgress<*> -> {
if (items.isEmpty()) {
State.FullData(currentState.pageCount, currentState.data)
} else {
State.Data(currentState.pageCount + 1, currentState.data + items)
}
}
else -> currentState
}.only()
}
is Change.PageLoadError -> {
when (currentState) {
is State.EmptyProgress -> State.EmptyError(change.error)
is State.Refresh<*> -> {
showError(Error.RefreshFailed)
State.Data(currentState.pageCount, currentState.data)
}
is State.NewPageProgress<*> -> {
showError(Error.NewPageFailed)
State.Data(currentState.pageCount, currentState.data)
}
else -> currentState
}.only()
}
}
override fun Flow<Effect>.handleSideEffect(): Flow<Change> = flatMapLatest { effect ->
flow {
when (effect) {
is Effect.LoadPage -> {
try {
val items = loadPage(effect.page)
emit(Change.NewPageLoaded(effect.page, items))
} catch (e: Exception) {
emit(Change.PageLoadError(e))
}
}
}
}
}
}

View File

@ -0,0 +1,22 @@
package ru.touchin.roboswag.pagination
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import ru.touchin.adapters.ItemAdapterDelegate
import ru.touchin.mvi_arch.core_pagination.R
import ru.touchin.mvi_test.core_ui.pagination.ProgressItem
import ru.touchin.roboswag.components.utils.UiUtils
class ProgressAdapterDelegate : ItemAdapterDelegate<RecyclerView.ViewHolder, ProgressItem>() {
override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder =
object : RecyclerView.ViewHolder(UiUtils.inflate(R.layout.item_progress, parent)) {}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
item: ProgressItem,
adapterPosition: Int,
collectionPosition: Int,
payloads: MutableList<Any>
) = Unit
}

View File

@ -0,0 +1,3 @@
package ru.touchin.mvi_test.core_ui.pagination
object ProgressItem

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_to_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ru.touchin.widget.Switcher
android:id="@+id/switcher"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/empty_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Не загрузилося" />
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/elements_recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false" />
</ru.touchin.widget.Switcher>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="paginator_new_page_error_message">New page load error. Try again.</string>
<string name="paginator_refresh_error_message">Refresh screen error. Try again.</string>
</resources>