Skip to content

Latest commit

 

History

History
686 lines (573 loc) · 24.1 KB

README.md

File metadata and controls

686 lines (573 loc) · 24.1 KB

GitHub top language


Banking-App-Mock-Compose

About the app

Banking-App-Mock-Compose is a mock android app written with Jetpack Compose

The primary purpose of the app is to develop a medium-size (~20K LoC) app using Jetpack Compose instead of Android Views and enjoy all Compose pitfalls :)

The app is built with a data -> domain <- ui architecture typical for real applications with only difference that there is no remote data source. Thus, all the banking info is mocked on data layer. However, some data like Cards and Transactions is cached locally for persistence.

Demo video:

banking-app-demo.mp4

Design reference: https://www.figma.com/community/file/1106055934725589367/Finance-Mobile-App-UI-KIT by Aparna Tati

Stack

App usage

Working credentials for the app:

Login: example@mail.com
Password: 1234567Ab
OTP: 1111

Use other credentials if you want to trigger an error.

If app asks to scan QR:

(or use QR generated in Profile)

Implementation plan

  • App core:
    • Splash screen
    • Navigation
    • Operation handling core (OperationResult class)
    • Popup messages (snackbar)
    • Error UI
    • App lock (PIN)
    • App lock (biometrics)
    • Data encryption
    • Permissions management
  • Onboarding slider
  • Sign up with email
  • Sign up with phone number
  • Restore password
  • OTP verification
  • Home screen:
    • Cards
    • Savings
    • Profile
    • Balance observable
  • Cards
    • Card list
    • Create card
    • Delete card
    • Default card
    • Card Statistics
  • Savings
    • Savings list
    • Saving details
  • Account operations
    • Send money
    • Top up
    • Request money
    • Pay
    • Transaction manager
    • Transaction history
    • Transaction contacts
  • Profile
    • Profile info
    • QR scanner
    • My QR
    • Edit profile
  • Account and security section
  • Help section
  • Notifications
  • App Settings
  • Logout
  • Terms and Conditions (WebView)

Technical details

The app uses Clean Architecture layers and MVI for presentation layer.

App layers

UI layer

UI layer

The app contains a root AppContainerScreen screen which servers for several purposes:

  • Show loading screen when app state prepared on start.
  • Prepare and pass conditional navigation to NavHost.
  • Setup CompositionLocal for NavHost.
// Root State
sealed class AppState {
    object Loading: AppState()

    data class Ready(
        val conditionalNavigation: ConditionalNavigation,
        val requireUnlock: Boolean = false
    ): AppState()

    data class InitFailure(val error: UiText): AppState()
}

// Root screen
@Composable
fun AppContainerScreen(viewModel: AppViewModel = koinViewModel()) {

    // Init jetpack navigation
    val navController = rememberNavController()
    
    // Preparations for CompositionLocal
    val snackBarHostState = remember { SnackbarHostState() }
    val hostCoroutineScope = rememberCoroutineScope()
    val permissionHelper =  koinInject<PermissionHelper>()

    val state = viewModel.appState.collectAsStateWithLifecycle().value

    when (state) {
        is AppState.Loading -> {
            AppLoadingScreen()
        }

        is AppState.Ready -> {
            CompositionLocalProvider(
                // "Global" snackBar that survives navigation
                LocalScopedSnackbarState provides ScopedSnackBarState(
                    value = snackBarHostState,
                    coroutineScope = hostCoroutineScope
                ),
                // Permissions utils to check and request runtime permissions
                LocalPermissionHelper provides permissionHelper
            ) {

                if (state.requireUnlock) {
                    // Show app lock screen with PIN / biometrics
                    LockScreen(...)
                }
                else {
                    // Nav host with all screens of the app
                    AppNavHost(
                        navController = navController,
                        // Pass conditional navigation
                        // Here we may redirect to login screen
                        conditionalNavigation = state.conditionalNavigation,
                        ...
                    )
                }
            }
        }

        // App init failed
        // E.g. we'are checking access token validity and receive some error
        is AppState.InitFailure -> {
            ErrorFullScreen(
                error = state.error,
                onRetry = { viewModel.emitIntent(AppIntent.EnterApp) }
            )
        }
    }
}

A typical screen is handled with 4 components:

  • State - an object with screen data and user input
  • Intent - an action triggered from screen (e.g. data load request or button click)
  • Screen @Composable - screen's UI
  • ViewModel - to manage UI's data in a lifecycle-aware way

Screen state is represented with data or sealed class:

// State
data class ScreenState(
    val isLoading: Boolean = true,
    val error: UiText? = null,
    val displayedText: String? = null,
    // One-off event
    val someEvent: StateEvent = consumed
)

An Intent is typically a sealed class to easilly handle it with exhaustive when condition:

// Intent
sealed class ScreenIntent {
    object Load: ScreenIntent()
    object SomeClick: ScreenIntent()
    ...
}

// Then in ViewModel
when (intent) {
    is Load -> { loadDataFromServer() }
    is SomeClick -> { handleSomeClick() }
}

A screen composable typically contains a nested ScreenUi composable to make it work with @Preview:

// Screen
@Composable
fun Screen(
    // Inject ViewModel
    viewModel: CardListViewModel = koinViewModel(),
    // Navigation callbacks
    onBack: () -> Unit,
    onAddCard: () -> Unit,
) {
    // Get composition local
    val permissionHelper = LocalPermisionHelper.current
    val context = LocalContext.current
    ...

    // Collect the state from ViewModel
    val state = viewModel.state.collectAsStateWithLifecycle().value

    // Show ui
    ScreenUi(state = state)

    // Init data load with some effect
    LaunchedEffect(Unit) {
        viewModel.emitIntent(ScreenIntent.Load)
    }

    // The EventEffect is a LaunchedEffect that will be executed, when the event is in its triggered state. 
    // When the event action was executed the effect calls the onConsumed callback to force ViewMode to set the event field to be consumed.
    EventEffect(
        event = state.someEvent,
        onConsumed = viewModel::onconsumeSomeEvent
    ) {
        // Do action, e.g. show a snackbar notification
    }
}

ViewModel gets data from one or several usecases and reduces it to state for ui. More complex cases may require Flows for multiple values and Deferred async/await for parallel requess.

class ScreenViewModel: ViewModel(
    private val someUseCase: SomeUseCase
) {
    private val _state = MutableStateFlow(ScreenState())
    val state = _state.asStateFlow()

    fun emitIntent(intent: ScreenIntent) {
        when (intent) {
            is ScreenIntent.Load {
                viewModelScope.launch {
                    // This extension wraps data returned by use case into
                    // OperationResult class which encapsulates errors
                    val res = OperationResult.runWrapped { 
                        someUsecase.execute()
                    }
                    
                    when (res) {
                        is OperationResult.Success -> {
                            // res.data is some domain model which may be mapped to ui
                            val resUi =  SomeUiModel.mapFromDomain(res.data)
                            reduceSuccess(resUi)

                            // Additionally, we may trigger some event on condition:
                            _state.update {
                                it.copy(someEvent = triggered)
                            }
                        }
                        is OperationResult.Failure -> {
                            // ErrorType is a domain error which should be mapped to error text
                            reduceError(res.error.errorType.asUiTextError())
                        }
                    }
                }
            }
            
            // Here screen state is updated
            fun reduceError(...) {}
            fun reduceSuccess(...) {}

            fun onConsumeSomeEvent() {
                _state.update {
                    it.copy(someEvent = consumed)
                }
            }
        }
    }
}
Domain layer

Domain layer

Building blocks:

  • Use cases
  • Domain models
  • Domain error handling
  • Repository interfaces

A use case is a class for specific app feature. By using use cases the presentation layer remains unaware of the data source details.

class GetHomeCardsUseCase(private val cardsRepository: CardsRepository) {
    // A use case contains only one public execute() method with a single responsibility
    suspend fun execute(): List<PaymentCard> {
        val allCards = cardsRepository.getCards()

        val (primary, other) = allCards.partition { it.isPrimary }

        val sortedPrimary = primary.sortedByDescending { it.addedDate }
        val sortedOther = other.sortedByDescending { it.addedDate }

        return (sortedPrimary + sortedOther).take(DISPLAYED_COUNT)
    }

    companion object {
        private const val DISPLAYED_COUNT = 3
    }
}

A domain OperationResult model is used to represent result of a operation that may either succeed or finish with business logic error.

sealed class OperationResult<out T> {
    data class Success<out T>(val data: T) : OperationResult<T>()

    data class Failure(val error: AppError) : OperationResult<Nothing>()

    fun isSuccess(): Boolean {
        return when (this) {
            is Success -> true
            is Failure -> false
        }
    }

    companion object {
        inline fun <R> runWrapped(block: () -> R): OperationResult<R> {
            return try {
                val res = block()
                Success(res)
            } catch (e: Exception) {
                when (e) {
                    is AppError -> Failure(e)
                    else -> Failure(AppError(ErrorType.fromThrowable(e)))
                }
            }
        }
    }
}

// Domain error types
enum class ErrorType {
    USER_NOT_FOUND,
    WRONG_PASSWORD,
    CARD_NOT_FOUND,
    INSUFFICIENT_CARD_BALANCE,
    UNKNOWN_ERROR,;

    companion object {
        fun fromThrowable(e: Throwable): ErrorType {
            // Here may be additional mapping depending on exception type
            return when (e) {
                is AppError -> e.errorType
                else -> UNKNOWN_ERROR
            }
        }
    }
}

AppError is an exception that can be used on all layers of the application:

data class AppError(val errorType: ErrorType): Exception()

// We can throw AppError in repositories
// It will be later handled in business logic or ui
override suspend fun getCardById(id: String): PaymentCard = withContext(coroutineDispatcher) {
    // Load card from DB or throw error
    val card = cardsDao.getCardByNumber(id) ?: throw AppError(ErrorType.CARD_NOT_FOUND)
    return@withContext mapCachedCardToDomain(card)
}

// Or detect AppError directly from exception
// Example of usage in ViewModel's CoroutineExceptionHandler
private val errorHandler = CoroutineExceptionHandler { _, throwable ->
    reduceError(ErrorType.fromThrowable(throwable))
}

viewModelScope.launch(errorHandler) {
    // Can safely call usecases here without wrapping in OperationResult
}

Domain layer also includes models for core app entities like Cards, Savings and balances. This is an example of domain model for money representation

// Wrapper class for money representation
// Used to encapsulation of chosen base types (Double, BigDecimal and so on)
// And to handle additional properties like currencies
data class MoneyAmount(
    val value: Float,
    val currency: BalanceCurrency = BalanceCurrency.USD,
) {
    operator fun plus(other: MoneyAmount): MoneyAmount {
        return this.copy(value = this.value + other.value)
    }

    operator fun minus(other: MoneyAmount): MoneyAmount {
        return this.copy(value = this.value - other.value)
    }

    operator fun compareTo(other: MoneyAmount): Int {
        return this.value.compareTo(other.value)
    }
}
Data layer

Data layer

Building blocks:

  • Repository implemetations
  • Cache models and DAOs
  • Workers

This is an example of a mock repository implementation for transaction caching and execution:

class TransactionRepositoryMock(
    private val workManager: WorkManager,
    private val transactionDao: TransactionDao,
    private val coroutineDispatcher: CoroutineDispatcher,
    private val contactsRepository: ContactsRepository
) : TransactionRepository {

    // Cache new transaction and start a Worker for it
    override suspend fun submitTransaction(payload: TransactionRowPayload) {
        val raw = TransactionEntity(
            type = payload.type,
            value = payload.amount,
            linkedContactId = payload.contactId,
            createdDate = System.currentTimeMillis(),
            recentStatus = TransactionStatus.PENDING,
            updatedStatusDate = System.currentTimeMillis(),
            cardId = payload.cardId
        )
        val savedId = transactionDao.addTransaction(raw)

        val data = Data.Builder()
            .putLong(TransactionWorker.TRANSACTION_ID_KEY, savedId)
            .build()

        val workRequest =
            OneTimeWorkRequestBuilder<TransactionWorker>()
                .setConstraints(
                    Constraints.Builder()
                        .setRequiredNetworkType(NetworkType.CONNECTED)
                        .setRequiresBatteryNotLow(false)
                        .build()
                )
                .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
                .setInputData(data)
                .build()

        workManager.enqueue(workRequest)
    }

    // Load transactions from cache with paging
    override suspend fun getTransactions(filterByType: TransactionType?): Flow<PagingData<Transaction>> {
        val contacts = contactsRepository.getContacts()

        return Pager(
            config = PagingConfig(
                pageSize = PAGE_MAX_SIZE,
                prefetchDistance = PREFETCH_DISTANCE
            ),
            pagingSourceFactory = {
                TransactionSource(
                    filterByType = filterByType,
                    transactionDao = transactionDao
                )
            }
        ).flow.map {
            it.map { cachedTx ->
                mapTransactionFromCache(cachedTx, contacts)
            }
        }
    }

    // Get transaction status observer
    override fun getTransactionStatusFlow(transactionId: Long): Flow<TransactionStatus> {
        return flow {
            // Emit last cached status
            while (true) {
                val tx = transactionDao.getTransaction(transactionId) ?: throw AppError(ErrorType.TRANSACTION_NOT_FOUND)
                emit(tx.recentStatus)

                delay(MOCK_TRANSACTION_STATUS_CHECK_DELAY)
            }
        }.flowOn(coroutineDispatcher)
    }

    private fun mapTransactionFromCache(entity: TransactionEntity, contacts: List<Contact>) : Transaction {
        return Transaction(
            id = entity.id,
            type = entity.type,
            value = entity.value,
            recentStatus = entity.recentStatus,
            linkedContact = when (entity.type) {
                TransactionType.TOP_UP -> null
                else -> entity.linkedContactId?.let { id -> contacts.find { contact -> contact.id == id } }
            },
            createdDate = entity.createdDate,
            updatedStatusDate = entity.updatedStatusDate
        )
    }

    companion object {
        private const val PAGE_MAX_SIZE = 5
        private const val PREFETCH_DISTANCE = 1
        private const val MOCK_TRANSACTION_STATUS_CHECK_DELAY = 5000L
    }
}

Feature details

App lock

App lock

Used material: https://habr.com/ru/companies/redmadrobot/articles/475112/

PIN lock:

  • use Password-Based Key Derivation Function to generate serect from PIN and salt
  • use EncryptedSharedPreferences to store secrect. So that PIN not stored in raw way.
private fun savePin(pin: String) {
    val salt = CryptoUtils.generateSalt()

    val secretKey = CryptoUtils.generatePbkdf2Key(
        passphraseOrPin = pin.toCharArray(),
        salt = salt
    )

    val encodedPinData = Base64.encodeToString(secretKey.encoded, Base64.DEFAULT)
    val encodedSalt = Base64.encodeToString(salt, Base64.DEFAULT)

    securedPreferences.edit()
        .putString(PIN_KEY, encodedPinData)
        .putString(PIN_SALT_KEY, encodedSalt)
        .apply()
}

private fun isPinValid(pin: String): Boolean {
    val storedSalt = securedPreferences.getString(PIN_SALT_KEY, null)
    val decodedSalt = Base64.decode(storedSalt, Base64.DEFAULT)

    val storedPinData = securedPreferences.getString(PIN_KEY, null)
    val decodedPinData = Base64.decode(storedPinData, Base64.DEFAULT)

    val enteredPinData = CryptoUtils.generatePbkdf2Key(pin.toCharArray(), decodedSalt)

    return decodedPinData contentEquals enteredPinData.encoded
}

Biometrics:

  • use androidx.biometric to authenticate

⚠️ Drawbacks of current implemetation:

  • CryptoObject from biometrics auth result is not used for PIN generation or data encryption. Biometric auth relies solely on onAuthenticationSucceeded(...) callback.
  • No data encryption provied (except EncryptedSharedPreferences).

This implementation is very basic and not appropriate for a real banking app. In this app it just used to showcase lock screen feature.

Permissions

Runtime permissions

App uses PermissionHelper class with PermissionX library and some custom logic under the hood.

val LocalPermissionHelper = compositionLocalOf<PermissionHelper> { error("No PermissionHelper provided") }

// use in Composable
val permissionHelper = LocalPermissionHelper.current
val permission = android.Manifest.permission.CAMERA

when (permissionHelper.checkIfPermissionGranted(context, permission)) {
    CheckPermissionResult.SHOULD_ASK_PERMISSION -> {
        permissionHelper.askForPermission(context, permission) { res ->
            when (res) {
                AskPermissionResult.GRANTED -> { ... }
                AskPermissionResult.REJECTED -> { ... }
            }
        }
    }

    CheckPermissionResult.SHOULD_REDIRECT_TO_SETTINGS -> { ... }
    CheckPermissionResult.PERMISSION_ALREADY_GRANTED -> { ... }
}

Used materials

  1. Authenticate me. If you can…
  2. Android: Simple MVI implementation with Jetpack Compose
  3. Animations in Jetpack Compose with examples
  4. Clickable SpannableText in Jetpack Compose
  5. Custom UI Component - Canvas and Jetpack Compose
  6. Exception handling in Kotlin Coroutines
  7. Field Validations using Jetpack Compose and MVVM
  8. Firebase Analytics + Jetpack Compose
  9. Formatting credit card number input in Jetpack compose Android
  10. How to Render Text as a QR code in Jetpack Compose
  11. How to Validate Fields Using Jetpack Compose in Android
  12. Input Validation With Clean Architecture In Jetpack Compose
  13. Jetpack Compose OTP input field
  14. Kotlin Coroutines patterns & anti-patterns
  15. Navigate back with result with Jetpack Compose
  16. Paging With Clean Architecture In Jetpack Compose
  17. Parallel API calls using Coroutines, having different return types