PinLockSreen на основе KeyStore. Kotlin. Jetpack Compose
Начало
В данной любительской статье разберемся, что такое KeyStore в контексте мобильной разработки, для чего нужен и применим его в крайне легком варианте. Также погрузимся в разработку экрана входа в ваше приложение. Статья будет разделена на 3 так называемых раздела — KeyStore, UI и ViewModel.
Целью данного гайда это вот такой экран с последующим переходом:
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, в случае, если это не кнопка ОК или удалить, то просто вписываю в контент кнопки текст.
Далее требуется создать вот такие точки для ввода и текст
Создаем два элемента — текст и сами точки
@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, там я опубликовал пет приложение с применением данного пинлока. Также, если вдруг что‑то было непонятно, то можете зайти и сами запустить приложение.