diff --git a/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/EncryptedPinCodeStorage.kt b/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/EncryptedPinCodeStorage.kt new file mode 100644 index 0000000000..ae3bd7b893 --- /dev/null +++ b/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/EncryptedPinCodeStorage.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.pin.impl.storage + +/** + * Should be implemented by any class that provides access to the encrypted PIN code. + * All methods are suspending in case there are async IO operations involved. + */ +interface EncryptedPinCodeStorage { + /** + * Returns the encrypted PIN code. + */ + suspend fun getPinCode(): String? + + /** + * Saves the encrypted PIN code to some persistable storage. + */ + suspend fun savePinCode(pinCode: String) + + /** + * Deletes the PIN code from some persistable storage. + */ + suspend fun deletePinCode() + + /** + * Returns whether the PIN code is stored or not. + */ + suspend fun hasPinCode(): Boolean +} diff --git a/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/PinCodeStore.kt b/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/PinCodeStore.kt new file mode 100644 index 0000000000..5c54cc26f9 --- /dev/null +++ b/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/PinCodeStore.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.pin.impl.storage + +interface PinCodeStore : EncryptedPinCodeStorage { + + interface Listener { + fun onPinSetUpChange(isConfigured: Boolean) + } + + /** + * Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time. + */ + suspend fun getRemainingPinCodeAttemptsNumber(): Int + + /** + * Should decrement the number of remaining PIN code attempts. + * @return The remaining attempts. + */ + suspend fun onWrongPin(): Int + + /** + * Resets the counter of attempts for PIN code and biometric access. + */ + suspend fun resetCounter() + + /** + * Adds a listener to be notified when the PIN code us created or removed. + */ + fun addListener(listener: Listener) + + /** + * Removes a listener to be notified when the PIN code us created or removed. + */ + fun removeListener(listener: Listener) +} + + diff --git a/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/SharedPreferencesPinCodeStore.kt b/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/SharedPreferencesPinCodeStore.kt new file mode 100644 index 0000000000..fc8155352f --- /dev/null +++ b/features/pin/impl/src/main/kotlin/io/element/android/features/pin/impl/storage/SharedPreferencesPinCodeStore.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.pin.impl.storage + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.withContext +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class SharedPreferencesPinCodeStore @Inject constructor( + private val dispatchers: CoroutineDispatchers, + private val sharedPreferences: SharedPreferences, +) : PinCodeStore { + + private val listeners = CopyOnWriteArrayList() + + override suspend fun getPinCode(): String? = withContext(dispatchers.io) { + sharedPreferences.getString(ENCODED_PIN_CODE_KEY, null) + } + + override suspend fun savePinCode(pinCode: String) = withContext(dispatchers.io) { + sharedPreferences.edit { + putString(ENCODED_PIN_CODE_KEY, pinCode) + } + withContext(dispatchers.main) { + listeners.forEach { it.onPinSetUpChange(isConfigured = true) } + } + } + + override suspend fun deletePinCode() = withContext(dispatchers.io) { + // Also reset the counters + resetCounter() + sharedPreferences.edit { + remove(ENCODED_PIN_CODE_KEY) + } + withContext(dispatchers.main) { + listeners.forEach { it.onPinSetUpChange(isConfigured = false) } + } + } + + override suspend fun hasPinCode(): Boolean = withContext(dispatchers.io) { + sharedPreferences.contains(ENCODED_PIN_CODE_KEY) + } + + override suspend fun getRemainingPinCodeAttemptsNumber(): Int = withContext(dispatchers.io) { + sharedPreferences.getInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT) + } + + override suspend fun onWrongPin(): Int = withContext(dispatchers.io) { + val remaining = getRemainingPinCodeAttemptsNumber() - 1 + sharedPreferences.edit { + putInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, remaining) + } + remaining + } + + override suspend fun resetCounter() = withContext(dispatchers.io) { + sharedPreferences.edit { + remove(REMAINING_PIN_CODE_ATTEMPTS_KEY) + remove(REMAINING_BIOMETRICS_ATTEMPTS_KEY) + } + } + + override fun addListener(listener: PinCodeStore.Listener) { + listeners.add(listener) + } + + override fun removeListener(listener: PinCodeStore.Listener) { + listeners.remove(listener) + } + + companion object { + private const val ENCODED_PIN_CODE_KEY = "ENCODED_PIN_CODE_KEY" + private const val REMAINING_PIN_CODE_ATTEMPTS_KEY = "REMAINING_PIN_CODE_ATTEMPTS_KEY" + private const val REMAINING_BIOMETRICS_ATTEMPTS_KEY = "REMAINING_BIOMETRICS_ATTEMPTS_KEY" + + private const val MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT = 3 + } +}