PinLockSreen на основе KeyStore. Kotlin. Jetpack Compose

Начало

В данной любительской статье разберемся, что такое KeyStore в контексте мобильной разработки, для чего нужен и применим его в крайне легком варианте. Также погрузимся в разработку экрана входа в ваше приложение. Статья будет разделена на 3 так называемых раздела — KeyStore, UI и ViewModel.

Целью данного гайда это вот такой экран с последующим переходом:

25f9533a14a32ee84f1059b626deb446.png

P.S. Хочу сразу предупредить, что мой код не является показательным или каким-то супер профессиональным, я просто делал, то, что мог, именно поэтому, если есть желание, то жду комментариев с пояснениями, где я мог лучше.

KeyStore

Инструмент для хранения и управления чувствительными данными, такими, как пароли, ключи, коды и сертификаты, в безопасном хранилище. Данный API используют не только в контексте мобильной разработки под Android, но и на серверах в качестве безопасного места для хранения сертификатов. Я не буду углубляться в понятие KeyStore, так как это достаточно специализированная тема, да и к тому же есть множество гайдов раскрывающие эту тему, в том числе на хабре (Хабр статья, Англоязычная от EditorialTeam in Java security — более подробная).

Мы же возьмем от него только самое необходимое, а точнее модули EncryptedSharedPreferences и MasterKey.

Перед использованием KeyStore необходимо его заиплементить в build.gradle

// Keystore
implementation("androidx.security:security-crypto:1.1.0-alpha06")
class KeystoreManager(context: Context) {
    private val masterKeyAlias = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val sharedPreferences = EncryptedSharedPreferences.create(
        context,
        "encrypted_prefs",
        masterKeyAlias,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun savePin(pin: String) {
        sharedPreferences.edit().putString("pin", pin).apply()
    }

    fun getPin(): String? {
        return sharedPreferences.getString("pin", null)
    }
}

Создаем такой класс для последующего использования в качестве хранилища. В начале создаем masterKey. Он используется для защиты как ключей, так и значений внутри SharedPreference. И выбираем схему шифрования для самого ключа. В данном случае используется AES-256 в режиме GCM (Galois/Counter Mode). Этот режим шифрования обеспечивает конфиденциальность и целостность данных, а также аутентификацию.
Для SharedPreference мы используем EncryptedSharedPreferences. PrefKeyEncryptionScheme схема шифрования для ключей в SharedPreferences, а PrefValueEncryptionScheme схема шифрования для значений в SharedPreferences.
И собственно сами методы по сохранению пина и получению его.

Является ли безопасным метод getPin ()? Да. Потому что используется с уже зашифрованным sharedprefs и дешифруется в контексте данного класса. Последующее использование будет гарантировать безопасность, только если вы не решите оставить лог с данным пином.

Разработка UI

Отчасти именно из-за того, что нигде в интернете нет более удобных решений для PinLockScreen с JetpackCompose я решил написать простенький гайд, для сохранения своих знаний и, возможно, помощи другим. К слову, есть библиотека на просторах гитхаба, осуществляющая UI пинлока (без какой-либо логики защиты), однако, использование сторонних библиотек для осуществления легких элементов является крайне нежелательным.

В качестве цветов я использую цвета, которые я прописал в Theme, сделано это для того, чтобы поддержать темную и светлую тему, вы можете использовать любые цвета.

Далее цвета в моем проекте определяются так:

MaterialTheme.colorScheme.tertiary

Самое главное в пинлоке это клавиатура, а она в свою очередь состоит из кнопок, сделаем кнопку, в которую будем подставлять content с аннотацией Composable, тк в качестве контента будет элемент Text ().

@Composable
private fun KeyButton(
    onClick: () -> Unit,
    shape: Shape = RoundedCornerShape(100),
    content: @Composable () -> Unit
) {
    Box(
        modifier = Modifier
            .padding(8.dp)
            .clip(shape)
            .background(
                color = MaterialTheme.colorScheme.tertiary,
                shape = RoundedCornerShape(100)
            )
            .clickable(onClick = onClick)
            .defaultMinSize(minWidth = 95.dp, minHeight = 95.dp)
            .padding(4.dp),
        contentAlignment = Alignment.Center
    ) {
        CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.secondary) {
            content()
        }
    }
}

Далее сама клавиатура, я не додумался ни до чего кроме как представить клавиатуру в виде списка из списков каждого ряда символов. Данный проект является учебным, поэтому я не выношу строки в отдельные ресурсы. Если вы делаете свой проект, который будет развиваться или используете это в коммерции, то следует все это перенести в ресурсы, а сам список кнопок вынести в отдельный объект или же класс.

@Composable
private fun Keyboard(
) {
    val listKeys = listOf(
        listOf("1", "2", "3"),
        listOf("4", "5", "6"),
        listOf("7", "8", "9"),
        listOf("del", "0", "OK")
    )

    Column(
        modifier = Modifier
            .background(color = MaterialTheme.colorScheme.background)
            .fillMaxSize()
            .padding(bottom = 100.dp),
        verticalArrangement = Arrangement.Bottom,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Spacer(modifier = Modifier.height(100.dp))

        listKeys.forEach { rows ->
            Row {
                rows.forEach {
                    KeyButton(
                        onClick = {
                           
                        }
                    ) {
                        when (it) {
                            "del" -> {
                                Icon(
                                    painter = painterResource(id = R.drawable.del_icon),
                                    contentDescription = "Clear",
                                    modifier = Modifier
                                        .size(30.dp),
                                    tint = MaterialTheme.colorScheme.secondary
                                )
                            }

                            "OK" -> {
                                Icon(
                                    imageVector = Icons.Default.Check,
                                    contentDescription = "Success",
                                    modifier = Modifier
                                        .size(35.dp),
                                    tint = MaterialTheme.colorScheme.secondary
                                )
                            }

                            else -> Text(
                                text = it,
                                fontSize = 30.sp,
                                fontWeight = FontWeight.Medium,
                                color = MaterialTheme.colorScheme.secondary
                            )
                        }
                    }
                }
            }
        }
    }
}

Column внутри, которого прохожусь по списку, образуя тем самым rows, они и являются рядами с кнопками. Для того, чтобы определить в столбце спецсимвол пользуюсь выражением when, в случае, если это не кнопка ОК или удалить, то просто вписываю в контент кнопки текст.
Далее требуется создать вот такие точки для ввода и текст

4e0f459d26da029eb4eff520cea679c0.png

Создаем два элемента — текст и сами точки

@Composable
private fun PinDot(isFilled: Boolean) {
    Box(
        modifier = Modifier
            .padding(10.dp)
            .size(30.dp)
            .background(
                if (isFilled) MaterialTheme.colorScheme.tertiary else
                    if (isSystemInDarkTheme()) White
                    else Color.Gray, CircleShape
            )
    )
}

В параметре передаю isFilled для того, чтобы потом использовать, как флаг для заполнения при вводе символа.

ViewModel

Для того, чтобы отображать текст ввода и переходить по навигации нужно сделать вьюмодельку, в ней будет основная логика вызовов действий, в том числе сохранение пароля в KeyStore.
Для самой ViewModel нам понадобится модель state. И интерфейс с events, которые будут использованы как действия в приложении.

data class PinLockState(
    val isAuthenticated: Boolean = false,
    val inputPin: String = "",
    val error: ErrorPin? = null,
    val confirmPin: String? = null
)

sealed interface PinLockEvent {

    data class AddDigit(val digit: String) : PinLockEvent
    data object DeleteDigit : PinLockEvent
    data object ClearPin : PinLockEvent
    data object CheckPin : PinLockEvent

}

В стейте лежит флажок isAuthenticated для того, чтобы понять, есть ли у пользователя уже пинкод или нет, inputPin — сам пинкод, куда будут входить вводимые значения пользователя, error — ошибки, я использую также в качестве флага, для последующего входа, confirmPin — тоже пинкод, только нужен для регистрации и только.

Сама ViewModel представлена ниже.

class PinLockViewModel(
    private val keystoreManager: KeystoreManager
) : ViewModel() {

    private val _state = MutableStateFlow(PinLockState())
    val state: StateFlow = _state

    init {
        val pinSet = keystoreManager.getPin().isNullOrBlank()
        _state.update {
            it.copy(
                isAuthenticated = !pinSet
            )
        }
    }

    fun onEvent(event: PinLockEvent) {
        when (event) {
            is PinLockEvent.DeleteDigit -> {
                _state.update {
                    it.copy(
                        inputPin = it.inputPin.substring(0, it.inputPin.length - 1)
                    )
                }
            }
            is PinLockEvent.AddDigit -> {
                _state.update {
                    it.copy(
                        inputPin = it.inputPin.plus(event.digit)
                    )
                }
            }
            is PinLockEvent.ClearPin -> {
                _state.update {
                    it.copy(
                        inputPin = ""
                    )
                }
            }
            is PinLockEvent.CheckPin -> {
                val currentState = _state.value
                if (currentState.inputPin.length == 4) {
                    if (currentState.isAuthenticated) {
                        val storedPin = keystoreManager.getPin()
                        if (currentState.inputPin == storedPin) {
                            _state.update {
                                it.copy(
                                    error = ErrorPin.SUCCESS
                                )
                            }
                        } else {
                            _state.update {
                                it.copy(
                                    error = ErrorPin.INCORRECT_PASS
                                )
                            }

                            onEvent(PinLockEvent.ClearPin)
                        }
                    } else {
                        if (currentState.confirmPin.isNullOrBlank()) {
                            _state.update {
                                it.copy(
                                    confirmPin = currentState.inputPin,
                                    error = ErrorPin.TRY_PIN
                                )
                            }
                            onEvent(PinLockEvent.ClearPin)
                        } else {
                            if (currentState.inputPin == currentState.confirmPin) {
                                keystoreManager.savePin(currentState.inputPin)
                                _state.value = currentState.copy(
                                    isAuthenticated = true,
                                    error = ErrorPin.SUCCESS,
                                    confirmPin = null
                                )
                            } else {
                                _state.value = currentState.copy(error = ErrorPin.INCORRECT_PASS, confirmPin = null)
                                onEvent(PinLockEvent.ClearPin)
                            }
                        }
                    }
                } else {
                    _state.value = currentState.copy(error = ErrorPin.NOT_ENOUGH_DIG)
                    onEvent(PinLockEvent.ClearPin)
                }
            }
        }
    }


}

enum class ErrorPin(val error: Int){
    SUCCESS(0),
    INCORRECT_PASS(1),
    NOT_ENOUGH_DIG(2),
    TRY_PIN(3)
}

В начале оглашаем StateFlow для последующего использования и в качестве init производим идентификацию, есть ли вообще у пользователя пароль или нет. Далее это действия (onEvent). Тут все достаточно просто кроме как CheckPin, но и там тоже все просто, если углубиться, сначала идет проверка на кол‑во символов, если недостаточно символов то выводится ошибка и пин стирается, далее проверка на аутентификацию, если пользователь не аутентифицирован, то идет логика регистрации пина и последующего сохранения в KeyStore, если аутентифицирован, то проходит проверка с соответствующими errors, кстати, про них, создал енум класс для обозначения каждой ошибки .

Далее требуется лишь соединить это с UI, представлено ниже.

@Composable
fun PinLockScreen(
    navController: NavController,
    viewModel: PinLockViewModel
) {
    val statePin by viewModel.state.collectAsState()
    LaunchedEffect(statePin.isAuthenticated, statePin.error) {
        if (statePin.isAuthenticated && statePin.error == ErrorPin.SUCCESS) {
            navController.popBackStack()
            navController.navigate("mainScreen")
        }
    }
    Keyboard(
        onEvent = viewModel::onEvent,
        statePin = statePin
    )
}


@Composable
private fun Keyboard(
    onEvent: (PinLockEvent) -> Unit,
    statePin: PinLockState
) {
    val listKeys = listOf(
        listOf("1", "2", "3"),
        listOf("4", "5", "6"),
        listOf("7", "8", "9"),
        listOf("del", "0", "OK")
    )

    Column(
        modifier = Modifier
            .background(color = MaterialTheme.colorScheme.background)
            .fillMaxSize()
            .padding(bottom = 100.dp),
        verticalArrangement = Arrangement.Bottom,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        var text = when(statePin.error) {
            ErrorPin.INCORRECT_PASS -> "Попробуйте ещё раз"
            ErrorPin.TRY_PIN -> "Введите пин-код ещё раз для подтверждения"
            ErrorPin.NOT_ENOUGH_DIG -> "Недостаточно цифр, попробуйте еще раз"
            else -> "Введите пин-код"
        }
        if (!statePin.isAuthenticated && statePin.error == null) {
            text = "Для регистрации  введите пин-код"
        }
        Text(
            text = text,
            fontSize = 18.sp,
            textAlign = TextAlign.Center,
            color = MaterialTheme.colorScheme.secondary
        )
        Spacer(modifier = Modifier.height(30.dp))
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            repeat(4) { index ->
                val isFilled = index < statePin.inputPin.length
                PinDot(isFilled = isFilled)
            }
        }

        Spacer(modifier = Modifier.height(100.dp))

        listKeys.forEach { rows ->
            Row {
                rows.forEach {
                    KeyButton(
                        onClick = {
                            when (it) {
                                "del" -> if (statePin.inputPin.isNotEmpty()) onEvent(PinLockEvent.DeleteDigit)
                                "OK" -> {
                                    onEvent(PinLockEvent.CheckPin)
                                }
                                else -> {
                                    if (statePin.inputPin.length < 4) onEvent(
                                        PinLockEvent.AddDigit(it)
                                    )
                                }
                            }
                        }
                    ) {
                        when (it) {
                            "del" -> {
                                Icon(
                                    painter = painterResource(id = R.drawable.del_icon),
                                    contentDescription = "Clear",
                                    modifier = Modifier
                                        .size(30.dp),
                                    tint = MaterialTheme.colorScheme.secondary
                                )
                            }

                            "OK" -> {
                                Icon(
                                    imageVector = Icons.Default.Check,
                                    contentDescription = "Success",
                                    modifier = Modifier
                                        .size(35.dp),
                                    tint = MaterialTheme.colorScheme.secondary
                                )
                            }

                            else -> Text(
                                text = it,
                                fontSize = 30.sp,
                                fontWeight = FontWeight.Medium,
                                color = MaterialTheme.colorScheme.secondary
                            )
                        }
                    }
                }
            }
        }
    }
}

Для перехода на следующую страничку использовал данный код в самом начале, он зависит от флагов, которые я установил в стейте.

LaunchedEffect(statePin.isAuthenticated, statePin.error) {
        if (statePin.isAuthenticated && statePin.error == ErrorPin.SUCCESS) {
            navController.popBackStack()
            navController.navigate("mainScreen")
        }
    }

Текст над точками ввода, также зависит напрямую от стейта.

var text = when(statePin.error) {
            ErrorPin.INCORRECT_PASS -> "Попробуйте ещё раз"
            ErrorPin.TRY_PIN -> "Введите пин-код ещё раз для подтверждения"
            ErrorPin.NOT_ENOUGH_DIG -> "Недостаточно цифр, попробуйте еще раз"
            else -> "Введите пин-код"
        }
        if (!statePin.isAuthenticated && statePin.error == null) {
            text = "Для регистрации  введите пин-код"
        }

В MainActivity это выглядит вот так. Для использования навигации следует имплементировать соответствующий модуль в build.gradle.

class MainActivity : ComponentActivity() {

    private val keystoreManager: KeystoreManager by lazy {
        KeystoreManager(applicationContext)
    }


    private val viewModelPinLock by viewModels(factoryProducer = {
        object : ViewModelProvider.Factory {
            override fun  create(modelClass: Class): T {
                return PinLockViewModel(keystoreManager) as T
            }
        }
    })

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            PassManagerTheme {
                val stateItem by viewModelItem.state.collectAsState()
                val navController = rememberNavController()
                NavHost(navController = navController, startDestination = "auth") {
                    composable("auth") {
                        PinLockScreen(
                            navController = navController,
                            viewModel = viewModelPinLock
                        )
                    }
                    composable("mainScreen") {
                        ItemScreen()
                    }

                }

            }
        }
    }
}

Итог

В целом, я доволен итогом, что я приобрел новые знания и сделал свою логику (не без грехов). Данная статья является в первую очередь скопищем моих выводов для самого же себя, хотя, если это станет кому‑то интересно или даже полезно, то буду рад.

У меня есть github, там я опубликовал пет приложение с применением данного пинлока. Также, если вдруг что‑то было непонятно, то можете зайти и сами запустить приложение.

© Habrahabr.ru