Re-implement lock screen feature using our own implementation.

This commit is contained in:
Jorge Martín 2022-05-04 16:48:23 +02:00 committed by Jorge Martin Espinosa
parent 32c6281dd2
commit b5aedd4626
114 changed files with 5190 additions and 285 deletions

2
.gitignore vendored
View File

@ -16,4 +16,4 @@
/fastlane/private
/fastlane/report.xml
/library/build
/**/build

View File

@ -252,11 +252,7 @@ dependencyAnalysis {
exclude("org.json:json") // Used in unit tests, overwrites the one bundled into Android
}
}
project(":library:ui-styles") {
onUnusedDependencies {
exclude("com.github.vector-im:PFLockScreen-Android") // False positive
}
}
project(":library:ui-styles")
project(":matrix-sdk-android") {
onUnusedDependencies {
exclude("io.reactivex.rxjava2:rxkotlin") // Transitively required for mocking realm as monarchy doesn't expose Rx

1
changelog.d/6217.feature Normal file
View File

@ -0,0 +1 @@
Improve lock screen implementation.

View File

@ -24,11 +24,13 @@ def excludes = [
def initializeReport(report, projects, classExcludes) {
projects.each { project -> project.apply plugin: 'jacoco' }
report.executionData { fileTree(rootProject.rootDir.absolutePath).include(
"**/build/outputs/unit_test_code_coverage/**/*.exec",
"**/build/outputs/code_coverage/**/coverage.ec"
) }
report.executionData {
fileTree(rootProject.rootDir.absolutePath).include(
"**/build/outputs/unit_test_code_coverage/**/*.exec",
"**/build/outputs/code_coverage/**/coverage.ec",
)
}
report.reports {
xml.enabled true
html.enabled true

View File

@ -28,13 +28,13 @@ def bigImageViewer = "1.8.1"
def jjwt = "0.11.5"
def vanniktechEmoji = "0.15.0"
def fragment = "1.4.1"
// Testing
def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819
def espresso = "3.4.0"
def androidxTest = "1.4.0"
def androidxOrchestrator = "1.4.1"
ext.libs = [
gradle : [
'gradlePlugin' : "com.android.tools.build:gradle:$gradle",
@ -50,11 +50,14 @@ ext.libs = [
androidx : [
'annotation' : "androidx.annotation:annotation:1.3.0",
'activity' : "androidx.activity:activity:1.4.0",
'annotations' : "androidx.annotation:annotation:1.3.0",
'appCompat' : "androidx.appcompat:appcompat:1.4.2",
'biometric' : "androidx.biometric:biometric:1.1.0",
'core' : "androidx.core:core-ktx:1.8.0",
'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1",
'exifinterface' : "androidx.exifinterface:exifinterface:1.3.3",
'fragmentKtx' : "androidx.fragment:fragment-ktx:1.4.1",
'fragmentKtx' : "androidx.fragment:fragment-ktx:$fragment",
'fragmentTesting' : "androidx.fragment:fragment-testing:$fragment",
'constraintLayout' : "androidx.constraintlayout:constraintlayout:2.1.4",
'work' : "androidx.work:work-runtime-ktx:2.7.1",
'autoFill' : "androidx.autofill:autofill:1.1.0",
@ -85,6 +88,7 @@ ext.libs = [
'dagger' : "com.google.dagger:dagger:$dagger",
'daggerCompiler' : "com.google.dagger:dagger-compiler:$dagger",
'hilt' : "com.google.dagger:hilt-android:$dagger",
'hiltAndroidTesting' : "com.google.dagger:hilt-android-testing:$dagger",
'hiltCompiler' : "com.google.dagger:hilt-compiler:$dagger"
],
squareup : [
@ -155,3 +159,5 @@ ext.libs = [
'junit' : "junit:junit:4.13.2"
]
]

View File

@ -56,8 +56,6 @@ dependencies {
implementation libs.google.material
// Pref theme
implementation libs.androidx.preferenceKtx
// PFLockScreen attrs
implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
// dialpad dimen
implementation 'im.dlg:android-dialer:1.2.5'
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<gradient
android:type="linear"
android:startColor="#f28433"
android:endColor="#e0574c"
android:angle="270" />
</shape>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid
android:color="#44FFFFFF"/>
<size
android:width="70dp"
android:height="70dp"/>
</shape>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke
android:color="@color/lockscreen_code"
android:width="1px"/>
<size
android:width="@dimen/lockscreen_code_size"
android:height="@dimen/lockscreen_code_size"/>
</shape>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke
android:color="@color/lockscreen_code"
android:width="1px"/>
<solid
android:color="@color/lockscreen_code"/>
<size
android:width="@dimen/lockscreen_code_size"
android:height="@dimen/lockscreen_code_size"/>
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<padding android:padding="1dp" />
<corners android:radius="5dp" />
<solid android:color="#44FFFFFF" />
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- NOTE: order is important (the first matching state(s) is what is rendered) -->
<item
android:state_checked="true"
android:drawable="@drawable/lockscreen_circle_code_fill"/>
<item
android:drawable="@drawable/lockscreen_circle_code_empty"/>
</selector>

View File

@ -0,0 +1,7 @@
<vector android:height="14.498462dp" android:viewportHeight="589"
android:viewportWidth="975" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:pathData="M951,24H302.88L34,294L302.88,565H951V24Z"
android:strokeColor="#000000" android:strokeWidth="48"/>
<path android:pathData="M411.5,120L757.5,467.5M757.5,120L411.5,467.5"
android:strokeColor="#000000" android:strokeWidth="48"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000" android:pathData="M17.81,4.47c-0.08,0 -0.16,-0.02 -0.23,-0.06C15.66,3.42 14,3 12.01,3c-1.98,0 -3.86,0.47 -5.57,1.41 -0.24,0.13 -0.54,0.04 -0.68,-0.2 -0.13,-0.24 -0.04,-0.55 0.2,-0.68C7.82,2.52 9.86,2 12.01,2c2.13,0 3.99,0.47 6.03,1.52 0.25,0.13 0.34,0.43 0.21,0.67 -0.09,0.18 -0.26,0.28 -0.44,0.28zM3.5,9.72c-0.1,0 -0.2,-0.03 -0.29,-0.09 -0.23,-0.16 -0.28,-0.47 -0.12,-0.7 0.99,-1.4 2.25,-2.5 3.75,-3.27C9.98,4.04 14,4.03 17.15,5.65c1.5,0.77 2.76,1.86 3.75,3.25 0.16,0.22 0.11,0.54 -0.12,0.7 -0.23,0.16 -0.54,0.11 -0.7,-0.12 -0.9,-1.26 -2.04,-2.25 -3.39,-2.94 -2.87,-1.47 -6.54,-1.47 -9.4,0.01 -1.36,0.7 -2.5,1.7 -3.4,2.96 -0.08,0.14 -0.23,0.21 -0.39,0.21zM9.75,21.79c-0.13,0 -0.26,-0.05 -0.35,-0.15 -0.87,-0.87 -1.34,-1.43 -2.01,-2.64 -0.69,-1.23 -1.05,-2.73 -1.05,-4.34 0,-2.97 2.54,-5.39 5.66,-5.39s5.66,2.42 5.66,5.39c0,0.28 -0.22,0.5 -0.5,0.5s-0.5,-0.22 -0.5,-0.5c0,-2.42 -2.09,-4.39 -4.66,-4.39 -2.57,0 -4.66,1.97 -4.66,4.39 0,1.44 0.32,2.77 0.93,3.85 0.64,1.15 1.08,1.64 1.85,2.42 0.19,0.2 0.19,0.51 0,0.71 -0.11,0.1 -0.24,0.15 -0.37,0.15zM16.92,19.94c-1.19,0 -2.24,-0.3 -3.1,-0.89 -1.49,-1.01 -2.38,-2.65 -2.38,-4.39 0,-0.28 0.22,-0.5 0.5,-0.5s0.5,0.22 0.5,0.5c0,1.41 0.72,2.74 1.94,3.56 0.71,0.48 1.54,0.71 2.54,0.71 0.24,0 0.64,-0.03 1.04,-0.1 0.27,-0.05 0.53,0.13 0.58,0.41 0.05,0.27 -0.13,0.53 -0.41,0.58 -0.57,0.11 -1.07,0.12 -1.21,0.12zM14.91,22c-0.04,0 -0.09,-0.01 -0.13,-0.02 -1.59,-0.44 -2.63,-1.03 -3.72,-2.1 -1.4,-1.39 -2.17,-3.24 -2.17,-5.22 0,-1.62 1.38,-2.94 3.08,-2.94 1.7,0 3.08,1.32 3.08,2.94 0,1.07 0.93,1.94 2.08,1.94s2.08,-0.87 2.08,-1.94c0,-3.77 -3.25,-6.83 -7.25,-6.83 -2.84,0 -5.44,1.58 -6.61,4.03 -0.39,0.81 -0.59,1.76 -0.59,2.8 0,0.78 0.07,2.01 0.67,3.61 0.1,0.26 -0.03,0.55 -0.29,0.64 -0.26,0.1 -0.55,-0.04 -0.64,-0.29 -0.49,-1.31 -0.73,-2.61 -0.73,-3.96 0,-1.2 0.23,-2.29 0.68,-3.24 1.33,-2.79 4.28,-4.6 7.51,-4.6 4.55,0 8.25,3.51 8.25,7.83 0,1.62 -1.38,2.94 -3.08,2.94s-3.08,-1.32 -3.08,-2.94c0,-1.07 -0.93,-1.94 -2.08,-1.94s-2.08,0.87 -2.08,1.94c0,1.71 0.66,3.31 1.87,4.51 0.95,0.94 1.86,1.46 3.27,1.85 0.27,0.07 0.42,0.35 0.35,0.61 -0.05,0.23 -0.26,0.38 -0.47,0.38z"/>
</vector>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/lockscreen_white_selector">
<item android:id="@android:id/mask">
<shape android:shape="oval">
<padding android:padding="1dp" />
<corners android:radius="5dp" />
<solid android:color="@color/lockscreen_white_selector"/>
</shape>
</item>
<item>
<selector>
<item android:state_selected="true">
<color android:color="@android:color/darker_gray"/>
</item>
<item android:state_activated="true">
<color android:color="@android:color/white"/>
</item>
<item>
<color android:color="@android:color/transparent"/>
</item>
</selector>
</item>
</ripple>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
android:exitFadeDuration="@android:integer/config_mediumAnimTime">
<item android:state_focused="true" android:state_enabled="false" android:state_pressed="true"
android:drawable="@drawable/lockscreen_circle_key_selector" />
<item android:state_focused="true" android:state_enabled="false"
android:drawable="@drawable/lockscreen_circle_key_selector" />
<item android:state_focused="true" android:state_pressed="true"
android:drawable="@drawable/lockscreen_circle_key_selector" />
<item android:state_focused="false" android:state_pressed="true"
android:drawable="@drawable/lockscreen_circle_key_selector" />
<item android:state_focused="true"
android:drawable="@drawable/lockscreen_circle_key_selector" />
<item
android:drawable="@drawable/lockscreen_circle_background" />
</selector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="lockscreen_button_size">60dp</dimen>
<dimen name="lockscreen_button_margin_vertical">15dp</dimen>
</resources>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="lockscreen_key_button_theme" format="reference|integer"/>
<attr name="lockscreen_theme" format="reference|integer"/>
<attr name="lockscreen_fingerprint_button_theme" format="reference|integer"/>
<attr name="lockscreen_delete_button_theme" format="reference|integer"/>
<attr name="lockscreen_code_view_theme" format="reference|integer"/>
<attr name="lockscreen_title_theme" format="reference|integer"/>
<attr name="lockscreen_subtitle_theme" format="reference|integer"/>
<attr name="lockscreen_hint_theme" format="reference|integer"/>
<attr name="lockscreen_next_theme" format="reference|integer"/>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="lockscreen_code">#ffffff</color>
<color name="lockscreen_white_selector">#66ffffff</color>
<color name="lockscreen_hint_color">#42000000</color>
<color name="lockscreen_warning_color">#f4511e</color>
<color name="lockscreen_success_color">#009688</color>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="lockscreen_button_size">70dp</dimen>
<dimen name="lockscreen_button_margin_vertical">25dp</dimen>
<dimen name="lockscreen_code_size">10dp</dimen>
<dimen name="lockscreen_code_margin">5dp</dimen>
</resources>

View File

@ -0,0 +1,17 @@
<resources>
<string name="lockscreen_cancel">Cancel</string>
<string name="lockscreen_use_pin">Use pin</string>
<string name="lockscreen_sign_in">Sign in</string>
<string name="lockscreen_next">Next</string>
<string name="lockscreen_forgot">Forgot?</string>
<string name="lockscreen_title">Input pin code or use biometric authentication</string>
<string name="lockscreen_fingerprint_not_recognized">Fingerprint not recognized. Try again</string>
<string name="lockscreen_fingerprint_success">Fingerprint recognized</string>
<string name="lockscreen_fingerprint_description">Confirm fingerprint to continue</string>
<string name="lockscreen_fingerprint_hint">Touch sensor</string>
<string name="lockscreen_description_fingerprint_icon">Fingerprint icon</string>
<string name="lockscreen_confirm_pin">Confirm PIN</string>
<string name="lockscreen_description_logo">Logo</string>
</resources>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="LockScreenStyle">
<item name="android:background">@drawable/lockscreen_background</item>
</style>
<style name="LockScreenButtonStyle" parent="Theme.AppCompat.Light">
<!-- Customize your theme here. -->
<item name="android:textColor">@android:color/white</item>
<item name="android:background">@drawable/lockscreen_touch_selector</item>
</style>
<style name="LockScreenFingerPrintButtonStyle">
<item name="android:src">@drawable/lockscreen_fingerprint</item>
<item name="android:padding">20dp</item>
</style>
<style name="LockScreenDeleteButtonStyle">
<item name="android:src">@drawable/lockscreen_delete</item>
<item name="android:padding">20dp</item>
</style>
<style name="CheckBox">
<item name="android:checkboxStyle">@style/LockScreenCodeStyle</item>
<item name="checkboxStyle">@style/LockScreenCodeStyle</item>
</style>
<style name="LockScreenCodeStyle">
<item name="android:button">@drawable/lockscreen_code_selector</item>
</style>
<style name="LockScreenNextTextStyle">
<item name="android:textColor">#9FFF</item>
<item name="android:textSize">18sp</item>
<item name="android:backgroundTint">#c66</item>
</style>
<style name="LockScreenHintTextStyle">
<item name="android:textColor">@android:color/white</item>
</style>
<style name="LockScreenTitleTextStyle">
<item name="android:textColor">@android:color/white</item>
<item name="android:textSize">18sp</item>
<item name="android:gravity">center</item>
</style>
</resources>

View File

@ -22,13 +22,13 @@
</style>
<style name="PinCodeDeleteButtonStyle">
<item name="android:src">@drawable/delete_lockscreen_pf</item>
<item name="android:src">@drawable/lockscreen_delete</item>
<item name="android:tint">?vctr_content_primary</item>
<item name="background">@drawable/bg_pin_key</item>
</style>
<style name="PinCodeFingerprintButtonStyle">
<item name="android:src">@drawable/fingerprint_lockscreen_pf</item>
<item name="android:src">@drawable/lockscreen_fingerprint</item>
<item name="android:tint">?vctr_content_primary</item>
<item name="background">@drawable/bg_pin_key</item>
</style>

View File

@ -111,14 +111,14 @@
<item name="preferenceTheme">@style/PreferenceThemeOverlay.v14.Material</item>
<item name="pf_lock_screen">@style/PinCodeScreenStyle</item>
<item name="pf_key_button">@style/PinCodeKeyButtonStyle</item>
<item name="pf_title">@style/PinCodeTitleStyle</item>
<item name="pf_hint">@style/PinCodeHintStyle</item>
<item name="pf_code_view">@style/PinCodeDotsViewStyle</item>
<item name="pf_delete_button">@style/PinCodeDeleteButtonStyle</item>
<item name="pf_fingerprint_button">@style/PinCodeFingerprintButtonStyle</item>
<item name="pf_next">@style/PinCodeNextButtonStyle</item>
<item name="lockscreen_theme">@style/PinCodeScreenStyle</item>
<item name="lockscreen_key_button_theme">@style/PinCodeKeyButtonStyle</item>
<item name="lockscreen_title_theme">@style/PinCodeTitleStyle</item>
<item name="lockscreen_hint_theme">@style/PinCodeHintStyle</item>
<item name="lockscreen_code_view_theme">@style/PinCodeDotsViewStyle</item>
<item name="lockscreen_delete_button_theme">@style/PinCodeDeleteButtonStyle</item>
<item name="lockscreen_fingerprint_button_theme">@style/PinCodeFingerprintButtonStyle</item>
<item name="lockscreen_next_theme">@style/PinCodeNextButtonStyle</item>
<item name="android:statusBarColor">@color/android_status_bar_background_dark</item>
<item name="android:navigationBarColor">@color/android_navigation_bar_background_dark</item>

View File

@ -111,14 +111,14 @@
<item name="preferenceTheme">@style/PreferenceThemeOverlay.v14.Material</item>
<item name="pf_lock_screen">@style/PinCodeScreenStyle</item>
<item name="pf_key_button">@style/PinCodeKeyButtonStyle</item>
<item name="pf_title">@style/PinCodeTitleStyle</item>
<item name="pf_hint">@style/PinCodeHintStyle</item>
<item name="pf_code_view">@style/PinCodeDotsViewStyle</item>
<item name="pf_delete_button">@style/PinCodeDeleteButtonStyle</item>
<item name="pf_fingerprint_button">@style/PinCodeFingerprintButtonStyle</item>
<item name="pf_next">@style/PinCodeNextButtonStyle</item>
<item name="lockscreen_theme">@style/PinCodeScreenStyle</item>
<item name="lockscreen_key_button_theme">@style/PinCodeKeyButtonStyle</item>
<item name="lockscreen_title_theme">@style/PinCodeTitleStyle</item>
<item name="lockscreen_hint_theme">@style/PinCodeHintStyle</item>
<item name="lockscreen_code_view_theme">@style/PinCodeDotsViewStyle</item>
<item name="lockscreen_delete_button_theme">@style/PinCodeDeleteButtonStyle</item>
<item name="lockscreen_fingerprint_button_theme">@style/PinCodeFingerprintButtonStyle</item>
<item name="lockscreen_next_theme">@style/PinCodeNextButtonStyle</item>
<!-- Use dark color, to have enough contrast with icons color. windowLightStatusBar is only available in API 23+ -->
<item name="android:statusBarColor">@color/android_status_bar_background_dark</item>

View File

@ -1,5 +1,5 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -14,9 +14,9 @@
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.securestorage
package org.matrix.android.sdk
import org.matrix.android.sdk.internal.util.system.BuildVersionSdkIntProvider
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
class TestBuildVersionSdkIntProvider : BuildVersionSdkIntProvider {
var value: Int = 0

View File

@ -14,40 +14,57 @@
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.securestorage
package org.matrix.android.sdk.api.securestorage
import android.os.Build
import android.util.Base64
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.spyk
import org.amshove.kluent.invoking
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldNotThrow
import org.amshove.kluent.shouldThrow
import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import org.matrix.android.sdk.InstrumentedTest
import org.matrix.android.sdk.api.util.fromBase64
import org.matrix.android.sdk.api.util.toBase64NoPadding
import org.matrix.android.sdk.TestBuildVersionSdkIntProvider
import java.io.ByteArrayOutputStream
import java.security.KeyStore
import java.security.KeyStoreException
import java.util.UUID
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.JVM)
class SecretStoringUtilsTest : InstrumentedTest {
class SecretStoringUtilsTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val buildVersionSdkIntProvider = TestBuildVersionSdkIntProvider()
private val secretStoringUtils = SecretStoringUtils(context(), buildVersionSdkIntProvider)
private val keyStore = spyk(KeyStore.getInstance("AndroidKeyStore")).also { it.load(null) }
private val secretStoringUtils = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider)
companion object {
const val TEST_STR = "This is something I want to store safely!"
}
@Before
fun setup() {
clearAllMocks()
}
@Test
fun testStringNominalCaseApi21() {
val alias = generateAlias()
buildVersionSdkIntProvider.value = Build.VERSION_CODES.LOLLIPOP
// Encrypt
val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias)
val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias)
// Decrypt
val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias)
val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias))
decrypted shouldBeEqualTo TEST_STR
secretStoringUtils.safeDeleteKey(alias)
}
@ -57,9 +74,9 @@ class SecretStoringUtilsTest : InstrumentedTest {
val alias = generateAlias()
buildVersionSdkIntProvider.value = Build.VERSION_CODES.M
// Encrypt
val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias)
val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias)
// Decrypt
val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias)
val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias))
decrypted shouldBeEqualTo TEST_STR
secretStoringUtils.safeDeleteKey(alias)
}
@ -69,9 +86,9 @@ class SecretStoringUtilsTest : InstrumentedTest {
val alias = generateAlias()
buildVersionSdkIntProvider.value = Build.VERSION_CODES.R
// Encrypt
val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias)
val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias)
// Decrypt
val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias)
val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias))
decrypted shouldBeEqualTo TEST_STR
secretStoringUtils.safeDeleteKey(alias)
}
@ -81,13 +98,13 @@ class SecretStoringUtilsTest : InstrumentedTest {
val alias = generateAlias()
buildVersionSdkIntProvider.value = Build.VERSION_CODES.LOLLIPOP
// Encrypt
val encrypted = secretStoringUtils.securelyStoreString(TEST_STR, alias)
val encrypted = secretStoringUtils.securelyStoreBytes(TEST_STR.toByteArray(), alias)
// Simulate a system upgrade
buildVersionSdkIntProvider.value = Build.VERSION_CODES.M
// Decrypt
val decrypted = secretStoringUtils.loadSecureSecret(encrypted, alias)
val decrypted = String(secretStoringUtils.loadSecureSecretBytes(encrypted, alias))
decrypted shouldBeEqualTo TEST_STR
secretStoringUtils.safeDeleteKey(alias)
}
@ -180,5 +197,56 @@ class SecretStoringUtilsTest : InstrumentedTest {
secretStoringUtils.safeDeleteKey(alias)
}
@Test
fun testEnsureKeyReturnsSymmetricKeyOnAndroidM() {
buildVersionSdkIntProvider.value = Build.VERSION_CODES.M
val alias = generateAlias()
val key = secretStoringUtils.ensureKey(alias)
key shouldBeInstanceOf KeyStore.SecretKeyEntry::class
secretStoringUtils.safeDeleteKey(alias)
}
@Test
fun testEnsureKeyReturnsPrivateKeyOnAndroidL() {
buildVersionSdkIntProvider.value = Build.VERSION_CODES.LOLLIPOP
val alias = generateAlias()
val key = secretStoringUtils.ensureKey(alias)
key shouldBeInstanceOf KeyStore.PrivateKeyEntry::class
secretStoringUtils.safeDeleteKey(alias)
}
@Test
fun testSafeDeleteCanHandleKeyStoreExceptions() {
every { keyStore.deleteEntry(any()) } throws KeyStoreException()
invoking { secretStoringUtils.safeDeleteKey(generateAlias()) } shouldNotThrow KeyStoreException::class
}
@Test
fun testLoadSecureSecretBytesWillThrowOnInvalidStreamFormat() {
invoking {
secretStoringUtils.loadSecureSecretBytes(byteArrayOf(255.toByte()), generateAlias())
} shouldThrow IllegalArgumentException::class
}
@Test
fun testLoadSecureSecretWillThrowOnInvalidStreamFormat() {
invoking {
secretStoringUtils.loadSecureSecret(byteArrayOf(255.toByte()).inputStream(), generateAlias())
} shouldThrow IllegalArgumentException::class
}
private fun generateAlias() = UUID.randomUUID().toString()
}
private fun ByteArray.toBase64NoPadding(): String {
return Base64.encodeToString(this, Base64.NO_PADDING or Base64.NO_WRAP)
}
private fun String.fromBase64(): ByteArray {
return Base64.decode(this, Base64.DEFAULT)
}

View File

@ -20,6 +20,7 @@ import android.content.Context
import dagger.BindsInstance
import dagger.Component
import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.securestorage.SecureStorageModule
import org.matrix.android.sdk.internal.auth.AuthModule
import org.matrix.android.sdk.internal.debug.DebugModule
import org.matrix.android.sdk.internal.di.MatrixComponent
@ -39,7 +40,8 @@ import org.matrix.android.sdk.internal.util.system.SystemModule
RawModule::class,
DebugModule::class,
SettingsModule::class,
SystemModule::class
SystemModule::class,
SecureStorageModule::class,
]
)
@MatrixScope
@ -51,7 +53,7 @@ internal interface TestMatrixComponent : MatrixComponent {
interface Factory {
fun create(
@BindsInstance context: Context,
@BindsInstance matrixConfiguration: MatrixConfiguration
@BindsInstance matrixConfiguration: MatrixConfiguration,
): TestMatrixComponent
}
}

View File

@ -17,6 +17,8 @@
package org.matrix.android.sdk.api
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import androidx.work.WorkManager
@ -30,6 +32,7 @@ import org.matrix.android.sdk.api.legacy.LegacySessionImporter
import org.matrix.android.sdk.api.network.ApiInterceptorListener
import org.matrix.android.sdk.api.network.ApiPath
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.securestorage.SecureStorageService
import org.matrix.android.sdk.api.settings.LightweightSettingsStorage
import org.matrix.android.sdk.internal.SessionManager
import org.matrix.android.sdk.internal.di.DaggerMatrixComponent
@ -64,6 +67,9 @@ class Matrix(context: Context, matrixConfiguration: MatrixConfiguration) {
@Inject internal lateinit var apiInterceptor: ApiInterceptor
@Inject internal lateinit var matrixWorkerFactory: MatrixWorkerFactory
@Inject internal lateinit var lightweightSettingsStorage: LightweightSettingsStorage
@Inject internal lateinit var secureStorageService: SecureStorageService
private val uiHandler = Handler(Looper.getMainLooper())
init {
val appContext = context.applicationContext
@ -76,7 +82,9 @@ class Matrix(context: Context, matrixConfiguration: MatrixConfiguration) {
.build()
WorkManager.initialize(appContext, configuration)
}
ProcessLifecycleOwner.get().lifecycle.addObserver(backgroundDetectionObserver)
uiHandler.post {
ProcessLifecycleOwner.get().lifecycle.addObserver(backgroundDetectionObserver)
}
}
/**
@ -115,6 +123,11 @@ class Matrix(context: Context, matrixConfiguration: MatrixConfiguration) {
*/
fun legacySessionImporter() = legacySessionImporter
/**
* Returns the SecureStorageService used to encrypt and decrypt sensitive data.
*/
fun secureStorageService(): SecureStorageService = secureStorageService
/**
* Get the worker factory. The returned value has to be provided to `WorkConfiguration.Builder()`.
*/

View File

@ -16,7 +16,7 @@
@file:Suppress("DEPRECATION")
package org.matrix.android.sdk.internal.session.securestorage
package org.matrix.android.sdk.api.securestorage
import android.annotation.SuppressLint
import android.content.Context
@ -25,7 +25,7 @@ import android.security.KeyPairGeneratorSpec
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.annotation.RequiresApi
import org.matrix.android.sdk.internal.util.system.BuildVersionSdkIntProvider
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@ -80,9 +80,11 @@ import javax.security.auth.x500.X500Principal
* Important: Keys stored in the keystore can be wiped out (depends of the OS version, like for example if you
* add a pin or change the schema); So you might and with a useless pile of bytes.
*/
internal class SecretStoringUtils @Inject constructor(
class SecretStoringUtils @Inject constructor(
private val context: Context,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider
private val keyStore: KeyStore,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
private val keyNeedsUserAuthentication: Boolean = false,
) {
companion object {
@ -94,14 +96,24 @@ internal class SecretStoringUtils @Inject constructor(
private const val FORMAT_1: Byte = 1
}
private val keyStore: KeyStore by lazy {
KeyStore.getInstance(ANDROID_KEY_STORE).apply {
load(null)
}
}
private val secureRandom = SecureRandom()
/**
* Allows creation of the crypto keys associated witht he [alias] before encrypting some value with it.
* @return A [KeyStore.Entry] with the keys.
*/
@SuppressLint("NewApi")
fun ensureKey(alias: String): KeyStore.Entry {
when {
buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> getOrGenerateSymmetricKeyForAliasM(alias)
else -> getOrGenerateKeyPairForAlias(alias).privateKey
}
return keyStore.getEntry(alias, null)
}
/**
* Deletes the key associated with the [keyAlias] and logs any [KeyStoreException] that could happen.
*/
fun safeDeleteKey(keyAlias: String) {
try {
keyStore.deleteEntry(keyAlias)
@ -121,24 +133,24 @@ internal class SecretStoringUtils @Inject constructor(
*/
@SuppressLint("NewApi")
@Throws(Exception::class)
fun securelyStoreString(secret: String, keyAlias: String): ByteArray {
fun securelyStoreBytes(secret: ByteArray, keyAlias: String): ByteArray {
return when {
buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> encryptStringM(secret, keyAlias)
else -> encryptString(secret, keyAlias)
buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> encryptBytesM(secret, keyAlias)
else -> encryptBytes(secret, keyAlias)
}
}
/**
* Decrypt a secret that was encrypted by #securelyStoreString().
* Decrypt a secret that was encrypted by [securelyStoreBytes].
*/
@SuppressLint("NewApi")
@Throws(Exception::class)
fun loadSecureSecret(encrypted: ByteArray, keyAlias: String): String {
fun loadSecureSecretBytes(encrypted: ByteArray, keyAlias: String): ByteArray {
encrypted.inputStream().use { inputStream ->
// First get the format
return when (val format = inputStream.read().toByte()) {
FORMAT_API_M -> decryptStringM(inputStream, keyAlias)
FORMAT_1 -> decryptString(inputStream, keyAlias)
FORMAT_API_M -> decryptBytesM(inputStream, keyAlias)
FORMAT_1 -> decryptBytes(inputStream, keyAlias)
else -> throw IllegalArgumentException("Unknown format $format")
}
}
@ -162,6 +174,22 @@ internal class SecretStoringUtils @Inject constructor(
}
}
fun getEncryptCipher(alias: String): Cipher {
val key = when (val keyEntry = ensureKey(alias)) {
is KeyStore.SecretKeyEntry -> keyEntry.secretKey
is KeyStore.PrivateKeyEntry -> keyEntry.certificate.publicKey
else -> throw IllegalStateException("Unknown KeyEntry type.")
}
val cipherMode = when {
buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> AES_MODE
else -> RSA_MODE
}
val cipher = Cipher.getInstance(cipherMode)
cipher.init(Cipher.ENCRYPT_MODE, key)
return cipher
}
@SuppressLint("NewApi")
@RequiresApi(Build.VERSION_CODES.M)
private fun getOrGenerateSymmetricKeyForAliasM(alias: String): SecretKey {
val secretKeyEntry = (keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)
@ -176,6 +204,13 @@ internal class SecretStoringUtils @Inject constructor(
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(128)
.apply {
setUserAuthenticationRequired(keyNeedsUserAuthentication)
if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.N) {
setInvalidatedByBiometricEnrollment(true)
}
}
.setUserAuthenticationRequired(keyNeedsUserAuthentication)
.build()
generator.init(keyGenSpec)
return generator.generateKey()
@ -216,19 +251,16 @@ internal class SecretStoringUtils @Inject constructor(
}
@RequiresApi(Build.VERSION_CODES.M)
private fun encryptStringM(text: String, keyAlias: String): ByteArray {
val secretKey = getOrGenerateSymmetricKeyForAliasM(keyAlias)
val cipher = Cipher.getInstance(AES_MODE)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
private fun encryptBytesM(byteArray: ByteArray, keyAlias: String): ByteArray {
val cipher = getEncryptCipher(keyAlias)
val iv = cipher.iv
// we happen the iv to the final result
val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8))
val encryptedBytes: ByteArray = cipher.doFinal(byteArray)
return formatMMake(iv, encryptedBytes)
}
@RequiresApi(Build.VERSION_CODES.M)
private fun decryptStringM(inputStream: InputStream, keyAlias: String): String {
private fun decryptBytesM(inputStream: InputStream, keyAlias: String): ByteArray {
val (iv, encryptedText) = formatMExtract(inputStream)
val secretKey = getOrGenerateSymmetricKeyForAliasM(keyAlias)
@ -237,10 +269,10 @@ internal class SecretStoringUtils @Inject constructor(
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
return String(cipher.doFinal(encryptedText), Charsets.UTF_8)
return cipher.doFinal(encryptedText)
}
private fun encryptString(text: String, keyAlias: String): ByteArray {
private fun encryptBytes(byteArray: ByteArray, keyAlias: String): ByteArray {
// we generate a random symmetric key
val key = ByteArray(16)
secureRandom.nextBytes(key)
@ -252,12 +284,12 @@ internal class SecretStoringUtils @Inject constructor(
val cipher = Cipher.getInstance(AES_MODE)
cipher.init(Cipher.ENCRYPT_MODE, sKey)
val iv = cipher.iv
val encryptedBytes: ByteArray = cipher.doFinal(text.toByteArray(Charsets.UTF_8))
val encryptedBytes: ByteArray = cipher.doFinal(byteArray)
return format1Make(encryptedKey, iv, encryptedBytes)
}
private fun decryptString(inputStream: InputStream, keyAlias: String): String {
private fun decryptBytes(inputStream: InputStream, keyAlias: String): ByteArray {
val (encryptedKey, iv, encrypted) = format1Extract(inputStream)
// we need to decrypt the key
@ -266,16 +298,13 @@ internal class SecretStoringUtils @Inject constructor(
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sKeyBytes, "AES"), spec)
return String(cipher.doFinal(encrypted), Charsets.UTF_8)
return cipher.doFinal(encrypted)
}
@RequiresApi(Build.VERSION_CODES.M)
@Throws(IOException::class)
private fun saveSecureObjectM(keyAlias: String, output: OutputStream, writeObject: Any) {
val secretKey = getOrGenerateSymmetricKeyForAliasM(keyAlias)
val cipher = Cipher.getInstance(AES_MODE)
cipher.init(Cipher.ENCRYPT_MODE, secretKey/*, spec*/)
val cipher = getEncryptCipher(keyAlias)
val iv = cipher.iv
val bos1 = ByteArrayOutputStream()
@ -362,10 +391,8 @@ internal class SecretStoringUtils @Inject constructor(
@Throws(Exception::class)
private fun rsaEncrypt(alias: String, secret: ByteArray): ByteArray {
val privateKeyEntry = getOrGenerateKeyPairForAlias(alias)
// Encrypt the text
val inputCipher = Cipher.getInstance(RSA_MODE)
inputCipher.init(Cipher.ENCRYPT_MODE, privateKeyEntry.certificate.publicKey)
val inputCipher = getEncryptCipher(alias)
val outputStream = ByteArrayOutputStream()
CipherOutputStream(outputStream, inputCipher).use {

View File

@ -0,0 +1,45 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.securestorage
import android.content.Context
import dagger.Binds
import dagger.Module
import dagger.Provides
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider
import java.security.KeyStore
@Module
internal abstract class SecureStorageModule {
@Module
companion object {
@Provides
fun provideKeyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
@Provides
fun provideSecretStoringUtils(
context: Context,
keyStore: KeyStore,
buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
): SecretStoringUtils = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider)
}
@Binds
abstract fun bindBuildVersionSdkIntProvider(provider: DefaultBuildVersionSdkIntProvider): BuildVersionSdkIntProvider
}

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.securestorage
package org.matrix.android.sdk.api.securestorage
import java.io.InputStream
import java.io.OutputStream

View File

@ -47,7 +47,6 @@ import org.matrix.android.sdk.api.session.pushrules.PushRuleService
import org.matrix.android.sdk.api.session.room.RoomDirectoryService
import org.matrix.android.sdk.api.session.room.RoomService
import org.matrix.android.sdk.api.session.search.SearchService
import org.matrix.android.sdk.api.session.securestorage.SecureStorageService
import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService
import org.matrix.android.sdk.api.session.signout.SignOutService
import org.matrix.android.sdk.api.session.space.SpaceService
@ -200,11 +199,6 @@ interface Session {
*/
fun syncService(): SyncService
/**
* Returns the SecureStorageService associated with the session.
*/
fun secureStorageService(): SecureStorageService
/**
* Returns the ProfileService associated with the session.
*/

View File

@ -14,9 +14,9 @@
* limitations under the License.
*/
package org.matrix.android.sdk.internal.util.system
package org.matrix.android.sdk.api.util
internal interface BuildVersionSdkIntProvider {
interface BuildVersionSdkIntProvider {
/**
* Return the current version of the Android SDK.
*/

View File

@ -14,12 +14,12 @@
* limitations under the License.
*/
package org.matrix.android.sdk.internal.util.system
package org.matrix.android.sdk.api.util
import android.os.Build
import javax.inject.Inject
internal class DefaultBuildVersionSdkIntProvider @Inject constructor() :
class DefaultBuildVersionSdkIntProvider @Inject constructor() :
BuildVersionSdkIntProvider {
override fun get() = Build.VERSION.SDK_INT
}

View File

@ -21,7 +21,7 @@ import androidx.core.content.edit
import io.realm.Realm
import io.realm.RealmConfiguration
import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.internal.session.securestorage.SecretStoringUtils
import org.matrix.android.sdk.api.securestorage.SecretStoringUtils
import timber.log.Timber
import java.security.SecureRandom
import javax.inject.Inject
@ -40,7 +40,7 @@ import javax.inject.Inject
*/
internal class RealmKeysUtils @Inject constructor(
context: Context,
private val secretStoringUtils: SecretStoringUtils
private val secretStoringUtils: SecretStoringUtils,
) {
private val rng = SecureRandom()
@ -71,7 +71,7 @@ internal class RealmKeysUtils @Inject constructor(
private fun createAndSaveKeyForDatabase(alias: String): ByteArray {
val key = generateKeyForRealm()
val encodedKey = Base64.encodeToString(key, Base64.NO_PADDING)
val toStore = secretStoringUtils.securelyStoreString(encodedKey, alias)
val toStore = secretStoringUtils.securelyStoreBytes(encodedKey.toByteArray(), alias)
sharedPreferences.edit {
putString("${ENCRYPTED_KEY_PREFIX}_$alias", Base64.encodeToString(toStore, Base64.NO_PADDING))
}
@ -85,7 +85,7 @@ internal class RealmKeysUtils @Inject constructor(
private fun extractKeyForDatabase(alias: String): ByteArray {
val encryptedB64 = sharedPreferences.getString("${ENCRYPTED_KEY_PREFIX}_$alias", null)
val encryptedKey = Base64.decode(encryptedB64, Base64.NO_PADDING)
val b64 = secretStoringUtils.loadSecureSecret(encryptedKey, alias)
val b64 = secretStoringUtils.loadSecureSecretBytes(encryptedKey, alias)
return Base64.decode(b64, Base64.NO_PADDING)
}

View File

@ -28,6 +28,8 @@ import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.securestorage.SecureStorageModule
import org.matrix.android.sdk.api.securestorage.SecureStorageService
import org.matrix.android.sdk.api.settings.LightweightSettingsStorage
import org.matrix.android.sdk.internal.SessionManager
import org.matrix.android.sdk.internal.auth.AuthModule
@ -53,7 +55,8 @@ import java.io.File
DebugModule::class,
SettingsModule::class,
SystemModule::class,
NoOpTestModule::class
NoOpTestModule::class,
SecureStorageModule::class,
]
)
@MatrixScope
@ -96,6 +99,8 @@ internal interface MatrixComponent {
fun sessionManager(): SessionManager
fun secureStorageService(): SecureStorageService
fun matrixWorkerFactory(): MatrixWorkerFactory
fun inject(matrix: Matrix)

View File

@ -14,9 +14,10 @@
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.securestorage
package org.matrix.android.sdk.internal.securestorage
import org.matrix.android.sdk.api.session.securestorage.SecureStorageService
import org.matrix.android.sdk.api.securestorage.SecretStoringUtils
import org.matrix.android.sdk.api.securestorage.SecureStorageService
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject

View File

@ -55,7 +55,6 @@ import org.matrix.android.sdk.api.session.pushrules.PushRuleService
import org.matrix.android.sdk.api.session.room.RoomDirectoryService
import org.matrix.android.sdk.api.session.room.RoomService
import org.matrix.android.sdk.api.session.search.SearchService
import org.matrix.android.sdk.api.session.securestorage.SecureStorageService
import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService
import org.matrix.android.sdk.api.session.signout.SignOutService
import org.matrix.android.sdk.api.session.space.SpaceService
@ -111,7 +110,6 @@ internal class DefaultSession @Inject constructor(
private val cryptoService: Lazy<DefaultCryptoService>,
private val defaultFileService: Lazy<FileService>,
private val permalinkService: Lazy<PermalinkService>,
private val secureStorageService: Lazy<SecureStorageService>,
private val profileService: Lazy<ProfileService>,
private val syncService: Lazy<SyncService>,
private val mediaService: Lazy<MediaService>,
@ -220,7 +218,6 @@ internal class DefaultSession @Inject constructor(
override fun eventService(): EventService = eventService.get()
override fun termsService(): TermsService = termsService.get()
override fun syncService(): SyncService = syncService.get()
override fun secureStorageService(): SecureStorageService = secureStorageService.get()
override fun profileService(): ProfileService = profileService.get()
override fun presenceService(): PresenceService = presenceService.get()
override fun accountService(): AccountService = accountService.get()

View File

@ -20,6 +20,7 @@ import dagger.BindsInstance
import dagger.Component
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.auth.data.SessionParams
import org.matrix.android.sdk.api.securestorage.SecureStorageModule
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.internal.crypto.CryptoModule
import org.matrix.android.sdk.internal.crypto.crosssigning.UpdateTrustWorker
@ -98,7 +99,8 @@ import org.matrix.android.sdk.internal.util.system.SystemModule
ThirdPartyModule::class,
SpaceModule::class,
PresenceModule::class,
RequestModule::class
RequestModule::class,
SecureStorageModule::class,
]
)
@SessionScope

View File

@ -41,7 +41,6 @@ import org.matrix.android.sdk.api.session.events.EventService
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.session.openid.OpenIdService
import org.matrix.android.sdk.api.session.permalinks.PermalinkService
import org.matrix.android.sdk.api.session.securestorage.SecureStorageService
import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService
import org.matrix.android.sdk.api.session.typing.TypingUsersTracker
import org.matrix.android.sdk.api.util.md5
@ -93,7 +92,6 @@ import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcesso
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessorCoroutine
import org.matrix.android.sdk.internal.session.room.tombstone.RoomTombstoneEventProcessor
import org.matrix.android.sdk.internal.session.securestorage.DefaultSecureStorageService
import org.matrix.android.sdk.internal.session.typing.DefaultTypingUsersTracker
import org.matrix.android.sdk.internal.session.user.accountdata.DefaultSessionAccountDataService
import org.matrix.android.sdk.internal.session.widgets.DefaultWidgetURLFormatter
@ -367,9 +365,6 @@ internal abstract class SessionModule {
@IntoSet
abstract fun bindEventSenderProcessorAsSessionLifecycleObserver(processor: EventSenderProcessorCoroutine): SessionLifecycleObserver
@Binds
abstract fun bindSecureStorageService(service: DefaultSecureStorageService): SecureStorageService
@Binds
abstract fun bindHomeServerCapabilitiesService(service: DefaultHomeServerCapabilitiesService): HomeServerCapabilitiesService

View File

@ -18,6 +18,8 @@ package org.matrix.android.sdk.internal.util.system
import dagger.Binds
import dagger.Module
import org.matrix.android.sdk.api.securestorage.SecureStorageService
import org.matrix.android.sdk.internal.securestorage.DefaultSecureStorageService
import org.matrix.android.sdk.internal.util.time.Clock
import org.matrix.android.sdk.internal.util.time.DefaultClock
@ -25,7 +27,7 @@ import org.matrix.android.sdk.internal.util.time.DefaultClock
internal abstract class SystemModule {
@Binds
abstract fun bindBuildVersionSdkIntProvider(provider: DefaultBuildVersionSdkIntProvider): BuildVersionSdkIntProvider
abstract fun bindSecureStorageService(service: DefaultSecureStorageService): SecureStorageService
@Binds
abstract fun bindClock(clock: DefaultClock): Clock

View File

@ -0,0 +1,31 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.util
import android.os.Build
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider
class DefaultBuildVersionSdkIntProviderTests {
@Test
fun getReturnsCurrentVersionFromBuild_Version_SDK_INT() {
val provider = DefaultBuildVersionSdkIntProvider()
provider.get() shouldBeEqualTo Build.VERSION.SDK_INT
}
}

View File

@ -361,6 +361,7 @@ dependencies {
implementation libs.androidx.core
implementation "androidx.media:media:1.6.0"
implementation "androidx.transition:transition:1.4.1"
implementation libs.androidx.biometric
implementation "org.threeten:threetenbp:1.4.0:no-tzdb"
implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.10.0"
@ -421,7 +422,6 @@ dependencies {
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation libs.androidx.autoFill
implementation 'jp.wasabeef:glide-transformations:4.3.0'
implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12'
implementation 'com.github.hyuwah:DraggableView:1.0.0'
// Custom Tab
@ -561,4 +561,5 @@ dependencies {
}
androidTestImplementation libs.mockk.mockkAndroid
androidTestUtil libs.androidx.orchestrator
debugImplementation libs.androidx.fragmentTesting
}

View File

@ -29,6 +29,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import im.vector.app.features.MainActivity
import im.vector.app.features.analytics.ui.consent.AnalyticsOptInActivity
import im.vector.app.features.home.HomeActivity
import org.hamcrest.CoreMatchers.not
import org.junit.Ignore
@ -106,6 +107,12 @@ class RegistrationTest {
.check(matches(isEnabled()))
.perform(closeSoftKeyboard(), click())
withIdlingResource(activityIdlingResource(AnalyticsOptInActivity::class.java)) {
onView(withId(R.id.later))
.check(matches(isDisplayed()))
.perform(click())
}
withIdlingResource(activityIdlingResource(HomeActivity::class.java)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2022 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 im.vector.app
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
class TestBuildVersionSdkIntProvider : BuildVersionSdkIntProvider {
var value: Int = 0
override fun get() = value
}

View File

@ -25,9 +25,11 @@ import android.text.style.ForegroundColorSpan
import android.text.style.StrikethroughSpan
import android.text.style.UnderlineSpan
import androidx.emoji2.text.EmojiCompat
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.InstrumentedTest
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeTrue
import org.junit.BeforeClass
import org.junit.FixMethodOrder
import org.junit.Ignore
import org.junit.Test
@ -42,6 +44,14 @@ import java.util.concurrent.TimeUnit
@Ignore
class SpanUtilsTest : InstrumentedTest {
companion object {
@BeforeClass
@JvmStatic
fun setupClass() {
EmojiCompat.init(InstrumentationRegistry.getInstrumentation().targetContext)
}
}
private val spanUtils = SpanUtils {
val emojiCompat = EmojiCompat.get()
emojiCompat.waitForInit()

View File

@ -0,0 +1,21 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen
object LockScreenTestConstants {
const val ALIAS = "some_alias"
}

View File

@ -0,0 +1,270 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.biometrics
import android.content.Intent
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
import androidx.lifecycle.lifecycleScope
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.TestBuildVersionSdkIntProvider
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguratorProvider
import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode
import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository
import im.vector.app.features.pin.lockscreen.tests.LockScreenTestActivity
import im.vector.app.features.pin.lockscreen.ui.fallbackprompt.FallbackBiometricDialogFragment
import im.vector.app.features.pin.lockscreen.utils.DevicePromptCheck
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.spyk
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class BiometricHelperTests {
private val biometricManager = mockk<BiometricManager>(relaxed = true)
private val lockScreenKeyRepository = mockk<LockScreenKeyRepository>(relaxed = true)
private val buildVersionSdkIntProvider = TestBuildVersionSdkIntProvider()
@Before
fun setup() {
clearAllMocks()
}
@Test
fun canUseWeakBiometricAuthReturnsTrueIfIsFaceUnlockEnabledAndCanAuthenticate() {
every { biometricManager.canAuthenticate(BIOMETRIC_WEAK) } returns BIOMETRIC_SUCCESS
val configuration = createDefaultConfiguration(isFaceUnlockEnabled = true)
val biometricUtils = createBiometricHelper(configuration)
biometricUtils.canUseWeakBiometricAuth.shouldBeTrue()
val biometricUtilsWithDisabledAuth = createBiometricHelper(createDefaultConfiguration(isFaceUnlockEnabled = false))
biometricUtilsWithDisabledAuth.canUseWeakBiometricAuth.shouldBeFalse()
every { biometricManager.canAuthenticate(BIOMETRIC_WEAK) } returns BIOMETRIC_ERROR_NONE_ENROLLED
biometricUtils.canUseWeakBiometricAuth.shouldBeFalse()
}
@Test
fun canUseStrongBiometricAuthReturnsTrueIfIsBiometricsEnabledAndCanAuthenticate() {
every { biometricManager.canAuthenticate(BIOMETRIC_STRONG) } returns BIOMETRIC_SUCCESS
val configuration = createDefaultConfiguration(isBiometricsEnabled = true)
val biometricUtils = createBiometricHelper(configuration)
biometricUtils.canUseStrongBiometricAuth.shouldBeTrue()
val biometricUtilsWithDisabledAuth = createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = false))
biometricUtilsWithDisabledAuth.canUseStrongBiometricAuth.shouldBeFalse()
every { biometricManager.canAuthenticate(BIOMETRIC_STRONG) } returns BIOMETRIC_ERROR_NONE_ENROLLED
biometricUtils.canUseStrongBiometricAuth.shouldBeFalse()
}
@Test
fun canUseDeviceCredentialAuthReturnsTrueIfIsDeviceCredentialsUnlockEnabledAndCanAuthenticate() {
every { biometricManager.canAuthenticate(DEVICE_CREDENTIAL) } returns BIOMETRIC_SUCCESS
val configuration = createDefaultConfiguration(isDeviceCredentialUnlockEnabled = true)
val biometricUtils = createBiometricHelper(configuration)
biometricUtils.canUseDeviceCredentialsAuth.shouldBeTrue()
val biometricUtilsWithDisabledAuth = createBiometricHelper(createDefaultConfiguration(isDeviceCredentialUnlockEnabled = false))
biometricUtilsWithDisabledAuth.canUseDeviceCredentialsAuth.shouldBeFalse()
every { biometricManager.canAuthenticate(DEVICE_CREDENTIAL) } returns BIOMETRIC_ERROR_NONE_ENROLLED
biometricUtils.canUseDeviceCredentialsAuth.shouldBeFalse()
}
@Test
fun isSystemAuthEnabledReturnsTrueIfAnyAuthenticationMethodIsAvailableAndEnabledAndSystemKeyExists() {
val biometricHelper = mockk<BiometricHelper>(relaxed = true) {
every { hasSystemKey } returns true
every { isSystemKeyValid } returns true
every { canUseAnySystemAuth } answers { callOriginal() }
every { isSystemAuthEnabledAndValid } answers { callOriginal() }
}
biometricHelper.isSystemAuthEnabledAndValid.shouldBeFalse()
every { biometricHelper.canUseWeakBiometricAuth } returns true
biometricHelper.isSystemAuthEnabledAndValid.shouldBeTrue()
every { biometricHelper.canUseWeakBiometricAuth } returns false
every { biometricHelper.canUseStrongBiometricAuth } returns true
biometricHelper.isSystemAuthEnabledAndValid.shouldBeTrue()
every { biometricHelper.canUseStrongBiometricAuth } returns false
every { biometricHelper.canUseDeviceCredentialsAuth } returns true
biometricHelper.isSystemAuthEnabledAndValid.shouldBeTrue()
every { biometricHelper.isSystemKeyValid } returns false
biometricHelper.isSystemAuthEnabledAndValid.shouldBeFalse()
}
@Test
fun hasSystemKeyReturnsKeyHelperHasSystemKey() {
val biometricUtils = createBiometricHelper(createDefaultConfiguration())
every { lockScreenKeyRepository.hasSystemKey() } returns true
biometricUtils.hasSystemKey.shouldBeTrue()
every { lockScreenKeyRepository.hasSystemKey() } returns false
biometricUtils.hasSystemKey.shouldBeFalse()
}
@Test
fun isSystemKeyValidReturnsKeyHelperIsSystemKeyValid() {
val biometricUtils = createBiometricHelper(createDefaultConfiguration())
every { lockScreenKeyRepository.isSystemKeyValid() } returns true
biometricUtils.isSystemKeyValid.shouldBeTrue()
every { lockScreenKeyRepository.isSystemKeyValid() } returns false
biometricUtils.isSystemKeyValid.shouldBeFalse()
}
@Test
fun disableAuthenticationDeletesSystemKeyAndCancelsPrompt() {
val biometricUtils = spyk(createBiometricHelper(createDefaultConfiguration()))
biometricUtils.disableAuthentication()
verify { lockScreenKeyRepository.deleteSystemKey() }
verify { biometricUtils.cancelPrompt() }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Ignore("This won't work in CI as the emulator won't have biometric auth enabled.")
@Test
fun authenticateShowsPrompt() = runTest {
val biometricUtils = createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = true))
every { lockScreenKeyRepository.isSystemKeyValid() } returns true
val latch = CountDownLatch(1)
with(ActivityScenario.launch(LockScreenTestActivity::class.java)) {
onActivity { activity ->
biometricUtils.authenticate(activity)
activity.supportFragmentManager.fragments.isNotEmpty().shouldBeTrue()
close()
latch.countDown()
}
}
latch.await(1, TimeUnit.SECONDS)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun authenticateInDeviceWithIssuesShowsFallbackPromptDialog() = runTest {
mockkStatic("kotlinx.coroutines.flow.FlowKt")
val mockAuthChannel: Channel<Boolean> = mockk(relaxed = true) {
// Empty flow to keep the dialog open
every { receiveAsFlow() } returns flowOf()
}
val biometricUtils = spyk(createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = true))) {
every { createAuthChannel() } returns mockAuthChannel
}
mockkObject(DevicePromptCheck)
every { DevicePromptCheck.isDeviceWithNoBiometricUI } returns true
every { lockScreenKeyRepository.isSystemKeyValid() } returns true
val latch = CountDownLatch(1)
val intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, LockScreenTestActivity::class.java)
with(ActivityScenario.launch<LockScreenTestActivity>(intent)) {
onActivity { activity ->
biometricUtils.authenticate(activity)
launch {
activity.supportFragmentManager.fragments.any { it is FallbackBiometricDialogFragment }.shouldBeTrue()
close()
latch.countDown()
}
}
}
latch.await(1, TimeUnit.SECONDS)
unmockkObject(DevicePromptCheck)
unmockkStatic("kotlinx.coroutines.flow.FlowKt")
}
@Test
fun authenticateCreatesSystemKeyIfNeededOnSuccessOnAndroidM() = runTest {
buildVersionSdkIntProvider.value = Build.VERSION_CODES.M
every { lockScreenKeyRepository.isSystemKeyValid() } returns true
val mockAuthChannel = Channel<Boolean>(capacity = 1)
val biometricUtils = spyk(createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = true))) {
every { createAuthChannel() } returns mockAuthChannel
every { authenticateWithPromptInternal(any(), any(), any()) } returns mockk()
}
val latch = CountDownLatch(1)
val intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, LockScreenTestActivity::class.java)
ActivityScenario.launch<LockScreenTestActivity>(intent).onActivity { activity ->
activity.lifecycleScope.launch {
launch {
mockAuthChannel.send(true)
mockAuthChannel.close()
}
biometricUtils.authenticate(activity).collect()
latch.countDown()
}
}
latch.await(1, TimeUnit.SECONDS)
verify { lockScreenKeyRepository.ensureSystemKey() }
}
private fun createBiometricHelper(configuration: LockScreenConfiguration): BiometricHelper {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val configProvider = LockScreenConfiguratorProvider(configuration)
return BiometricHelper(context, lockScreenKeyRepository, configProvider, biometricManager, buildVersionSdkIntProvider)
}
private fun createDefaultConfiguration(
mode: LockScreenMode = LockScreenMode.VERIFY,
pinCodeLength: Int = 4,
isBiometricsEnabled: Boolean = false,
isFaceUnlockEnabled: Boolean = false,
isDeviceCredentialUnlockEnabled: Boolean = false,
needsNewCodeValidation: Boolean = false,
otherChanges: LockScreenConfiguration.() -> LockScreenConfiguration = { this },
): LockScreenConfiguration = LockScreenConfiguration(
mode,
pinCodeLength,
isBiometricsEnabled,
isFaceUnlockEnabled,
isDeviceCredentialUnlockEnabled,
needsNewCodeValidation
).let(otherChanges)
}

View File

@ -0,0 +1,155 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.crypto
import android.os.Build
import android.security.keystore.KeyPermanentlyInvalidatedException
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.TestBuildVersionSdkIntProvider
import io.mockk.every
import io.mockk.spyk
import io.mockk.verify
import org.amshove.kluent.invoking
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue
import org.amshove.kluent.shouldThrow
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.securestorage.SecretStoringUtils
import java.security.KeyStore
class KeyStoreCryptoTests {
private val alias = "some_alias"
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
private val versionProvider = TestBuildVersionSdkIntProvider().also { it.value = Build.VERSION_CODES.M }
private val secretStoringUtils = spyk(SecretStoringUtils(context, keyStore, versionProvider))
private val keyStoreCrypto = spyk(
KeyStoreCrypto(alias, false, context, versionProvider, keyStore, secretStoringUtils)
)
@After
fun setup() {
keyStore.deleteEntry(alias)
}
@Test
fun ensureKeyChecksValidityOfKeyAndThrows() {
keyStore.containsAlias(alias) shouldBe false
val exception = KeyPermanentlyInvalidatedException()
every { secretStoringUtils.getEncryptCipher(any()) } throws exception
invoking { keyStoreCrypto.ensureKey() } shouldThrow exception
keyStoreCrypto.hasValidKey() shouldBe false
}
@Test
fun hasValidKeyChecksValidityOfKey() {
runCatching { keyStoreCrypto.ensureKey() }
keyStoreCrypto.hasValidKey() shouldBe true
val exception = KeyPermanentlyInvalidatedException()
every { secretStoringUtils.getEncryptCipher(any()) } throws exception
runCatching { keyStoreCrypto.ensureKey() }
keyStoreCrypto.hasValidKey() shouldBe false
}
@Test
fun hasKeyChecksIfKeyExists() {
keyStoreCrypto.hasKey() shouldBe false
keyStoreCrypto.ensureKey()
keyStoreCrypto.hasKey() shouldBe true
keyStore.containsAlias(keyStoreCrypto.alias)
keyStoreCrypto.deleteKey()
keyStoreCrypto.hasKey() shouldBe false
}
@Test
fun deleteKeyRemovesTheKey() {
keyStore.containsAlias(alias) shouldBe false
keyStoreCrypto.ensureKey()
keyStore.containsAlias(alias) shouldBe true
keyStoreCrypto.deleteKey()
keyStore.containsAlias(alias) shouldBe false
}
@Test
fun checkEncryptionAndDecryptionOfStringsWorkAsExpected() {
val original = "some plain text"
val encryptedString = keyStoreCrypto.encryptToString(original)
val encryptedBytes = keyStoreCrypto.encrypt(original)
val result = keyStoreCrypto.decryptToString(encryptedString)
val resultFromBytes = keyStoreCrypto.decryptToString(encryptedBytes)
result shouldBeEqualTo original
resultFromBytes shouldBeEqualTo original
}
@Test
fun checkEncryptionAndDecryptionWorkAsExpected() {
val original = "some plain text".toByteArray()
val encryptedBytes = keyStoreCrypto.encrypt(original)
val encryptedString = keyStoreCrypto.encryptToString(original)
val result = keyStoreCrypto.decrypt(encryptedBytes)
val resultFromString = keyStoreCrypto.decrypt(encryptedString)
result shouldBeEqualTo original
resultFromString shouldBeEqualTo original
}
@Test
fun hasValidKeyReturnsFalseWhenKeyPermanentlyInvalidatedExceptionIsThrown() {
every { keyStoreCrypto.hasKey() } returns true
every { secretStoringUtils.getEncryptCipher(any()) } throws KeyPermanentlyInvalidatedException()
keyStoreCrypto.hasValidKey().shouldBeFalse()
}
@Test
fun hasValidKeyReturnsFalseWhenKeyDoesNotExist() {
every { keyStoreCrypto.hasKey() } returns false
keyStoreCrypto.hasValidKey().shouldBeFalse()
}
@Test
fun hasValidKeyReturnsIfKeyExistsOnAndroidL() {
versionProvider.value = Build.VERSION_CODES.LOLLIPOP
every { keyStoreCrypto.hasKey() } returns true
keyStoreCrypto.hasValidKey().shouldBeTrue()
every { keyStoreCrypto.hasKey() } returns false
keyStoreCrypto.hasValidKey().shouldBeFalse()
}
@Test
fun getCryptoObjectUsesCipherFromSecretStoringUtils() {
keyStoreCrypto.getCryptoObject()
verify { secretStoringUtils.getEncryptCipher(any()) }
every { secretStoringUtils.getEncryptCipher(any()) } throws KeyPermanentlyInvalidatedException()
invoking { keyStoreCrypto.getCryptoObject() } shouldThrow KeyPermanentlyInvalidatedException::class
}
}

View File

@ -0,0 +1,184 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.crypto
import android.security.keystore.KeyPermanentlyInvalidatedException
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.features.settings.VectorPreferences
import io.mockk.clearAllMocks
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.coInvoking
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue
import org.amshove.kluent.shouldNotThrow
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider
import java.security.KeyStore
class LockScreenKeyRepositoryTests {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val buildVersionSdkIntProvider = DefaultBuildVersionSdkIntProvider()
private val keyStoreCryptoFactory: KeyStoreCrypto.Factory = mockk {
every { provide(any(), any()) } answers {
KeyStoreCrypto(arg(0), false, context, buildVersionSdkIntProvider, keyStore)
}
}
private lateinit var lockScreenKeyRepository: LockScreenKeyRepository
private val pinCodeMigrator: PinCodeMigrator = mockk(relaxed = true)
private val vectorPreferences: VectorPreferences = mockk(relaxed = true)
private val keyStore: KeyStore by lazy {
KeyStore.getInstance(LockScreenCryptoConstants.ANDROID_KEY_STORE).also { it.load(null) }
}
@Before
fun setup() {
lockScreenKeyRepository = spyk(LockScreenKeyRepository("base", pinCodeMigrator, vectorPreferences, keyStoreCryptoFactory))
}
@After
fun tearDown() {
clearAllMocks()
keyStore.deleteEntry("base.pin_code")
keyStore.deleteEntry("base.system")
}
@Test
fun ensureSystemKeyCreatesSystemKeyIfNeeded() {
lockScreenKeyRepository.ensureSystemKey()
lockScreenKeyRepository.hasSystemKey().shouldBeTrue()
}
@Test
fun encryptPinCodeCreatesPinCodeKey() {
lockScreenKeyRepository.encryptPinCode("1234")
lockScreenKeyRepository.hasPinCodeKey().shouldBeTrue()
}
@Test
fun decryptPinCodeDecryptsEncodedPinCode() {
val decodedPinCode = "1234"
val pinCodeKeyCryptoMock = mockk<KeyStoreCrypto>(relaxed = true) {
every { decryptToString(any<String>()) } returns decodedPinCode
}
every { keyStoreCryptoFactory.provide(any(), any()) } returns pinCodeKeyCryptoMock
lockScreenKeyRepository.decryptPinCode("SOME_VALUE") shouldBeEqualTo decodedPinCode
}
@Test
fun isSystemKeyValidReturnsWhatKeyStoreCryptoHasValidKeyReplies() {
val systemKeyCryptoMock = mockk<KeyStoreCrypto>(relaxed = true) {
every { hasKey() } returns true
}
every { keyStoreCryptoFactory.provide(any(), any()) } returns systemKeyCryptoMock
every { systemKeyCryptoMock.hasValidKey() } returns false
lockScreenKeyRepository.isSystemKeyValid().shouldBeFalse()
every { systemKeyCryptoMock.hasValidKey() } returns true
lockScreenKeyRepository.isSystemKeyValid().shouldBeTrue()
}
@Test
fun hasSystemKeyReturnsTrueAfterSystemKeyIsCreated() {
lockScreenKeyRepository.hasSystemKey().shouldBeFalse()
lockScreenKeyRepository.ensureSystemKey()
lockScreenKeyRepository.hasSystemKey().shouldBeTrue()
}
@Test
fun hasPinCodeKeyReturnsTrueAfterPinCodeKeyIsCreated() {
lockScreenKeyRepository.hasPinCodeKey().shouldBeFalse()
lockScreenKeyRepository.encryptPinCode("1234")
lockScreenKeyRepository.hasPinCodeKey().shouldBeTrue()
}
@Test
fun deleteSystemKeyRemovesTheKeyFromKeyStore() {
lockScreenKeyRepository.ensureSystemKey()
lockScreenKeyRepository.hasSystemKey().shouldBeTrue()
lockScreenKeyRepository.deleteSystemKey()
lockScreenKeyRepository.hasSystemKey().shouldBeFalse()
}
@Test
fun deletePinCodeKeyRemovesTheKeyFromKeyStore() {
lockScreenKeyRepository.encryptPinCode("1234")
lockScreenKeyRepository.hasPinCodeKey().shouldBeTrue()
lockScreenKeyRepository.deletePinCodeKey()
lockScreenKeyRepository.hasPinCodeKey().shouldBeFalse()
}
@Test
fun migrateKeysIfNeededReturnsEarlyIfNotNeeded() = runTest {
every { pinCodeMigrator.isMigrationNeeded() } returns false
lockScreenKeyRepository.migrateKeysIfNeeded()
coVerify(exactly = 0) { pinCodeMigrator.migrate(any()) }
}
@Test
fun migrateKeysIfNeededWillMigratePinCodeAndKeys() = runTest {
every { pinCodeMigrator.isMigrationNeeded() } returns true
lockScreenKeyRepository.migrateKeysIfNeeded()
coVerify { pinCodeMigrator.migrate(any()) }
}
@Test
fun migrateKeysIfNeededWillCreateSystemKeyIfNeeded() = runTest {
every { pinCodeMigrator.isMigrationNeeded() } returns true
every { vectorPreferences.useBiometricsToUnlock() } returns true
every { lockScreenKeyRepository.ensureSystemKey() } returns mockk()
lockScreenKeyRepository.migrateKeysIfNeeded()
verify { lockScreenKeyRepository.ensureSystemKey() }
}
@Test
fun migrateKeysIfNeededWillHandleKeyPermanentlyInvalidatedException() = runTest {
every { pinCodeMigrator.isMigrationNeeded() } returns true
every { vectorPreferences.useBiometricsToUnlock() } returns true
every { lockScreenKeyRepository.ensureSystemKey() } throws KeyPermanentlyInvalidatedException()
coInvoking { lockScreenKeyRepository.migrateKeysIfNeeded() } shouldNotThrow KeyPermanentlyInvalidatedException::class
verify { lockScreenKeyRepository.ensureSystemKey() }
}
}

View File

@ -0,0 +1,236 @@
/*
* Copyright (c) 2022 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.
*/
@file:Suppress("DEPRECATION")
package im.vector.app.features.pin.lockscreen.crypto
import android.os.Build
import android.security.KeyPairGeneratorSpec
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.preference.PreferenceManager
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.SharedPrefPinCodeStore
import im.vector.app.features.pin.lockscreen.crypto.LockScreenCryptoConstants.ANDROID_KEY_STORE
import im.vector.app.features.pin.lockscreen.crypto.LockScreenCryptoConstants.LEGACY_PIN_CODE_KEY_ALIAS
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.securestorage.SecretStoringUtils
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
import java.math.BigInteger
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec
import java.util.Calendar
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
import javax.security.auth.x500.X500Principal
import kotlin.math.abs
class PinCodeMigratorTests {
private val alias = UUID.randomUUID().toString()
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val pinCodeStore: PinCodeStore = spyk(
SharedPrefPinCodeStore(PreferenceManager.getDefaultSharedPreferences(InstrumentationRegistry.getInstrumentation().context))
)
private val keyStore: KeyStore = spyk(KeyStore.getInstance(ANDROID_KEY_STORE)).also { it.load(null) }
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider = mockk {
every { get() } returns Build.VERSION_CODES.M
}
private val secretStoringUtils: SecretStoringUtils = spyk(
SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider)
)
private val pinCodeMigrator = spyk(PinCodeMigrator(pinCodeStore, keyStore, secretStoringUtils, buildVersionSdkIntProvider))
@After
fun tearDown() {
if (keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS)) {
keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS)
}
if (keyStore.containsAlias(alias)) {
keyStore.deleteEntry(alias)
}
runBlocking { pinCodeStore.deletePinCode() }
}
@Test
fun isMigrationNeededReturnsTrueIfLegacyKeyExists() {
pinCodeMigrator.isMigrationNeeded() shouldBe false
generateLegacyKey()
pinCodeMigrator.isMigrationNeeded() shouldBe true
}
@Test
fun migrateWillReturnEarlyIfPinCodeDoesNotExist() = runTest {
every { pinCodeMigrator.isMigrationNeeded() } returns false
coEvery { pinCodeStore.getPinCode() } returns null
pinCodeMigrator.migrate(alias)
coVerify(exactly = 0) { pinCodeMigrator.getDecryptedPinCode() }
verify(exactly = 0) { secretStoringUtils.securelyStoreBytes(any(), any()) }
coVerify(exactly = 0) { pinCodeStore.savePinCode(any()) }
verify(exactly = 0) { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) }
}
@Test
fun migrateWillReturnEarlyIfIsNotNeeded() = runTest {
every { pinCodeMigrator.isMigrationNeeded() } returns false
coEvery { pinCodeMigrator.getDecryptedPinCode() } returns "1234"
every { secretStoringUtils.securelyStoreBytes(any(), any()) } returns ByteArray(0)
pinCodeMigrator.migrate(alias)
coVerify(exactly = 0) { pinCodeMigrator.getDecryptedPinCode() }
verify(exactly = 0) { secretStoringUtils.securelyStoreBytes(any(), any()) }
coVerify(exactly = 0) { pinCodeStore.savePinCode(any()) }
verify(exactly = 0) { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) }
}
@Test
fun migratePinCodeM() = runTest {
val pinCode = "1234"
saveLegacyPinCode(pinCode)
pinCodeMigrator.migrate(alias)
coVerify { pinCodeMigrator.getDecryptedPinCode() }
verify { secretStoringUtils.securelyStoreBytes(any(), any()) }
coVerify { pinCodeStore.savePinCode(any()) }
verify { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) }
val decodedPinCode = String(secretStoringUtils.loadSecureSecretBytes(Base64.decode(pinCodeStore.getPinCode().orEmpty(), Base64.NO_WRAP), alias))
decodedPinCode shouldBeEqualTo pinCode
keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS) shouldBe false
keyStore.containsAlias(alias) shouldBe true
}
@Test
fun migratePinCodeL() = runTest {
val pinCode = "1234"
every { buildVersionSdkIntProvider.get() } returns Build.VERSION_CODES.LOLLIPOP
saveLegacyPinCode(pinCode)
pinCodeMigrator.migrate(alias)
coVerify { pinCodeMigrator.getDecryptedPinCode() }
verify { secretStoringUtils.securelyStoreBytes(any(), any()) }
coVerify { pinCodeStore.savePinCode(any()) }
verify { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) }
val decodedPinCode = String(secretStoringUtils.loadSecureSecretBytes(Base64.decode(pinCodeStore.getPinCode().orEmpty(), Base64.NO_WRAP), alias))
decodedPinCode shouldBeEqualTo pinCode
keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS) shouldBe false
keyStore.containsAlias(alias) shouldBe true
}
private fun generateLegacyKey() {
if (keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS)) return
if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M) {
generateLegacyKeyM()
} else {
generateLegacyKeyL()
}
}
private fun generateLegacyKeyL() {
val start = Calendar.getInstance()
val end = Calendar.getInstance().also { it.add(Calendar.YEAR, 25) }
val keyGen = KeyPairGenerator
.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEY_STORE)
val spec = KeyPairGeneratorSpec.Builder(context)
.setAlias(LEGACY_PIN_CODE_KEY_ALIAS)
.setSubject(X500Principal("CN=$LEGACY_PIN_CODE_KEY_ALIAS"))
.setSerialNumber(BigInteger.valueOf(abs(LEGACY_PIN_CODE_KEY_ALIAS.hashCode()).toLong()))
.setEndDate(end.time)
.setStartDate(start.time)
.setSerialNumber(BigInteger.ONE)
.setSubject(X500Principal("CN = Secured Preference Store, O = Devliving Online"))
.build()
keyGen.initialize(spec)
keyGen.generateKeyPair()
}
private fun generateLegacyKeyM() {
val keyGenerator: KeyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEY_STORE)
keyGenerator.initialize(
KeyGenParameterSpec.Builder(LEGACY_PIN_CODE_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
.build()
)
keyGenerator.generateKeyPair()
}
private suspend fun saveLegacyPinCode(value: String) {
generateLegacyKey()
val publicKey = keyStore.getCertificate(LEGACY_PIN_CODE_KEY_ALIAS).publicKey
val cipher = getLegacyCipher()
if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M) {
val unrestrictedKey = KeyFactory.getInstance(publicKey.algorithm).generatePublic(X509EncodedKeySpec(publicKey.encoded))
val spec = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
cipher.init(Cipher.ENCRYPT_MODE, unrestrictedKey, spec)
} else {
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
}
val bytes = cipher.doFinal(value.toByteArray())
val encryptedPinCode = Base64.encodeToString(bytes, Base64.NO_WRAP)
pinCodeStore.savePinCode(encryptedPinCode)
}
private fun getLegacyCipher(): Cipher {
return when (buildVersionSdkIntProvider.get()) {
Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1 -> getCipherL()
else -> getCipherM()
}
}
private fun getCipherL(): Cipher {
val provider = if (buildVersionSdkIntProvider.get() < Build.VERSION_CODES.M) "AndroidOpenSSL" else "AndroidKeyStoreBCWorkaround"
val transformation = "RSA/ECB/PKCS1Padding"
return Cipher.getInstance(transformation, provider)
}
private fun getCipherM(): Cipher {
val transformation = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
return Cipher.getInstance(transformation)
}
}

View File

@ -0,0 +1,21 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.tests
import androidx.fragment.app.FragmentActivity
class LockScreenTestActivity : FragmentActivity()

View File

@ -0,0 +1,122 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui.fallbackprompt
import android.view.View
import android.widget.Button
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.testing.launchFragment
import androidx.lifecycle.Lifecycle
import androidx.test.platform.app.InstrumentationRegistry
import com.airbnb.mvrx.Mavericks
import im.vector.app.R
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.receiveAsFlow
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import java.util.concurrent.CountDownLatch
class FallbackBiometricDialogFragmentTests {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun dismissTriggersOnDismissCallback() {
val latch = CountDownLatch(1)
val fragmentScenario = launchFragment<FallbackBiometricDialogFragment>(noArgsBundle())
fragmentScenario.onFragment { fragment ->
fragment.onDismiss = { latch.countDown() }
fragment.dismiss()
}
latch.await()
}
@Test
fun argsModifyUI() {
val latch = CountDownLatch(1)
val args = FallbackBiometricDialogFragment.Args(
title = "Title",
description = "Description",
cancelActionText = "Cancel text",
)
val fragmentScenario = launchFragment<FallbackBiometricDialogFragment>(bundleOf(Mavericks.KEY_ARG to args))
fragmentScenario.onFragment { fragment ->
val view = fragment.requireView()
view.findViewById<Button>(R.id.cancel_button).text.toString() shouldBeEqualTo args.cancelActionText
view.findViewById<TextView>(R.id.fingerprint_description).text.toString() shouldBeEqualTo args.description
(fragment as DialogFragment).requireDialog().window?.attributes?.title shouldBeEqualTo args.title
latch.countDown()
}
latch.await()
}
@Test
fun onSuccessRendersStateAndDismisses() {
val latch = CountDownLatch(1)
val authFlow = MutableSharedFlow<Boolean>(replay = 1)
val fragmentScenario = launchFragment<FallbackBiometricDialogFragment>(noArgsBundle())
fragmentScenario.moveToState(Lifecycle.State.CREATED)
fragmentScenario.onFragment { fragment ->
fragment.onDismiss = { latch.countDown() }
fragment.authenticationFlow = authFlow
fragmentScenario.moveToState(Lifecycle.State.RESUMED)
// Espresso wasn't fast enough to catch this value
authFlow.tryEmit(true)
fragment.requireView().statusText() shouldBeEqualTo context.getString(R.string.lockscreen_fingerprint_success)
}
latch.await()
}
@Test
fun onFailureRendersStateAndResetsItBackAfterDelay() {
val latch = CountDownLatch(1)
val authFlow = MutableSharedFlow<Boolean>(replay = 1)
val fragmentScenario = launchFragment<FallbackBiometricDialogFragment>(noArgsBundle())
fragmentScenario.moveToState(Lifecycle.State.CREATED)
fragmentScenario.onFragment { fragment ->
fragment.authenticationFlow = authFlow
fragmentScenario.moveToState(Lifecycle.State.RESUMED)
authFlow.tryEmit(false)
fragment.requireView().statusText() shouldBeEqualTo context.getString(R.string.lockscreen_fingerprint_not_recognized)
latch.countDown()
}
latch.await()
}
@Test
fun onErrorDismissesDialog() {
val latch = CountDownLatch(1)
val authChannel = Channel<Boolean>(capacity = 1)
val fragmentScenario = launchFragment<FallbackBiometricDialogFragment>(noArgsBundle())
fragmentScenario.moveToState(Lifecycle.State.CREATED)
fragmentScenario.onFragment { fragment ->
fragment.onDismiss = { latch.countDown() }
fragment.authenticationFlow = authChannel.receiveAsFlow()
fragmentScenario.moveToState(Lifecycle.State.RESUMED)
authChannel.close(Exception())
}
latch.await()
}
private fun noArgsBundle() = bundleOf(Mavericks.KEY_ARG to FallbackBiometricDialogFragment.Args())
private fun View.statusText(): String = findViewById<TextView>(R.id.fingerprint_status).text.toString()
}

View File

@ -0,0 +1,139 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.views
import androidx.test.platform.app.InstrumentationRegistry
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class LockScreenCodeViewTests {
lateinit var lockScreenCodeView: LockScreenCodeView
@Before
fun setup() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
lockScreenCodeView = LockScreenCodeView(context).apply { codeLength = 4 }
}
@Test
fun addingCharactersChangesEnteredDigits() {
lockScreenCodeView.onCharInput('A')
lockScreenCodeView.enteredDigits shouldBeEqualTo 1
}
@Test
fun onCharInputReturnsUpdatedDigitCount() {
val digits = lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo digits
}
@Test
fun whenDigitsEqualCodeLengthCompletionCallbackIsCalled() {
val latch = CountDownLatch(1)
lockScreenCodeView.onCodeCompleted = LockScreenCodeView.CodeCompletedListener { latch.countDown() }
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 4
latch.await(1, TimeUnit.SECONDS)
}
@Test
fun whenCodeIsCompletedCannotAddMoreDigits() {
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 4
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 4
}
@Test
fun whenChangingCodeLengthCodeIsReset() {
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 1
lockScreenCodeView.codeLength = 10
lockScreenCodeView.enteredDigits shouldBeEqualTo 0
}
@Test
fun changingCodeLengthToTheSameValueDoesNothing() {
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 1
lockScreenCodeView.codeLength = lockScreenCodeView.codeLength
lockScreenCodeView.enteredDigits shouldBeEqualTo 1
}
@Test
fun clearResetsEnteredDigits() {
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 1
lockScreenCodeView.clearCode()
lockScreenCodeView.enteredDigits shouldBeEqualTo 0
}
@Test
fun deleteLastRemovesLastDigit() {
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 2
lockScreenCodeView.deleteLast()
lockScreenCodeView.enteredDigits shouldBeEqualTo 1
}
@Test
fun deleteLastReturnsUpdatedDigitCount() {
lockScreenCodeView.onCharInput('1')
val digits = lockScreenCodeView.deleteLast()
lockScreenCodeView.enteredDigits shouldBeEqualTo digits
}
@Test
fun deleteLastCannotRemoveDigitIfCodeIsEmpty() {
lockScreenCodeView.onCharInput('1')
lockScreenCodeView.enteredDigits shouldBeEqualTo 1
lockScreenCodeView.deleteLast()
lockScreenCodeView.deleteLast()
lockScreenCodeView.enteredDigits shouldBeEqualTo 0
}
}

View File

@ -13,6 +13,9 @@
<activity
android:name="com.facebook.flipper.android.diagnostics.FlipperDiagnosticActivity"
android:exported="true" />
<!-- Used for UI tests to display the BiometricPrompt. It's normal that it appears as an error. -->
<activity android:exported="false" android:name=".features.pin.lockscreen.tests.LockScreenTestActivity" />
</application>
</manifest>

View File

@ -195,7 +195,7 @@ class MainActivity : VectorBaseActivity<ActivityMainBinding>(), UnlockedActivity
vectorPreferences.clearPreferences()
uiStateRepository.reset()
pinLocker.unlock()
pinCodeStore.deleteEncodedPin()
pinCodeStore.deletePinCode()
vectorAnalytics.onSignOut()
vectorSessionStore.clear()
}

View File

@ -23,6 +23,7 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.util.toBase64NoPadding
@ -30,7 +31,8 @@ import java.io.ByteArrayOutputStream
class ReAuthViewModel @AssistedInject constructor(
@Assisted val initialState: ReAuthState,
private val session: Session
private val session: Session,
private val matrix: Matrix,
) : VectorViewModel<ReAuthState, ReAuthActions, ReAuthEvents>(initialState) {
@AssistedFactory
@ -58,7 +60,7 @@ class ReAuthViewModel @AssistedInject constructor(
is ReAuthActions.ReAuthWithPass -> {
val safeForIntentCypher = ByteArrayOutputStream().also {
it.use {
session.secureStorageService().securelyStoreObject(action.password, initialState.resultKeyStoreAlias, it)
matrix.secureStorageService().securelyStoreObject(action.password, initialState.resultKeyStoreAlias, it)
}
}.toByteArray().toBase64NoPadding()
_viewEvents.post(ReAuthEvents.PasswordFinishSuccess(safeForIntentCypher))

View File

@ -25,6 +25,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.LiveEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.listeners.StepProgressListener
import org.matrix.android.sdk.api.session.Session
@ -42,7 +43,8 @@ import timber.log.Timber
import javax.inject.Inject
class KeysBackupRestoreSharedViewModel @Inject constructor(
private val stringProvider: StringProvider
private val stringProvider: StringProvider,
private val matrix: Matrix,
) : ViewModel() {
data class KeySource(
@ -186,7 +188,7 @@ class KeysBackupRestoreSharedViewModel @Inject constructor(
fun handleGotSecretFromSSSS(cipherData: String, alias: String) {
try {
cipherData.fromBase64().inputStream().use { ins ->
val res = session.secureStorageService().loadSecureSecret<Map<String, String>>(ins, alias)
val res = matrix.secureStorageService().loadSecureSecret<Map<String, String>>(ins, alias)
val secret = res?.get(KEYBACKUP_SECRET_SSSS_NAME)
if (secret == null) {
_navigateEvent.postValue(

View File

@ -36,6 +36,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.listeners.ProgressListener
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.securestorage.IntegrityResult
@ -86,7 +87,8 @@ data class SharedSecureStorageViewState(
class SharedSecureStorageViewModel @AssistedInject constructor(
@Assisted private val initialState: SharedSecureStorageViewState,
private val stringProvider: StringProvider,
private val session: Session
private val session: Session,
private val matrix: Matrix,
) :
VectorViewModel<SharedSecureStorageViewState, SharedSecureStorageAction, SharedSecureStorageViewEvent>(initialState) {
@ -249,7 +251,7 @@ class SharedSecureStorageViewModel @AssistedInject constructor(
_viewEvents.post(SharedSecureStorageViewEvent.HideModalLoading)
val safeForIntentCypher = ByteArrayOutputStream().also {
it.use {
session.secureStorageService().securelyStoreObject(decryptedSecretMap as Map<String, String>, initialState.resultKeyStoreAlias, it)
matrix.secureStorageService().securelyStoreObject(decryptedSecretMap as Map<String, String>, initialState.resultKeyStoreAlias, it)
}
}.toByteArray().toBase64NoPadding()
_viewEvents.post(SharedSecureStorageViewEvent.FinishSuccess(safeForIntentCypher))
@ -345,7 +347,7 @@ class SharedSecureStorageViewModel @AssistedInject constructor(
_viewEvents.post(SharedSecureStorageViewEvent.HideModalLoading)
val safeForIntentCypher = ByteArrayOutputStream().also {
it.use {
session.secureStorageService().securelyStoreObject(decryptedSecretMap as Map<String, String>, initialState.resultKeyStoreAlias, it)
matrix.secureStorageService().securelyStoreObject(decryptedSecretMap as Map<String, String>, initialState.resultKeyStoreAlias, it)
}
}.toByteArray().toBase64NoPadding()
_viewEvents.post(SharedSecureStorageViewEvent.FinishSuccess(safeForIntentCypher))

View File

@ -39,6 +39,7 @@ import im.vector.app.features.raw.wellknown.isSecureBackupRequired
import im.vector.app.features.raw.wellknown.secureBackupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.UserPasswordAuth
@ -70,6 +71,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
private val rawService: RawService,
private val bootstrapTask: BootstrapCrossSigningTask,
private val migrationTask: BackupToQuadSMigrationTask,
private val matrix: Matrix,
) : VectorViewModel<BootstrapViewState, BootstrapActions, BootstrapViewEvents>(initialState) {
private var doesKeyBackupExist: Boolean = false
@ -274,7 +276,7 @@ class BootstrapSharedViewModel @AssistedInject constructor(
uiaContinuation?.resume(DefaultBaseAuth(session = pendingAuth?.session ?: ""))
}
is BootstrapActions.PasswordAuthDone -> {
val decryptedPass = session.secureStorageService()
val decryptedPass = matrix.secureStorageService()
.loadSecureSecret<String>(action.password.fromBase64().inputStream(), ReAuthActivity.DEFAULT_RESULT_KEYSTORE_ALIAS)
uiaContinuation?.resume(
UserPasswordAuth(

View File

@ -34,6 +34,7 @@ import im.vector.app.features.raw.wellknown.getElementWellknown
import im.vector.app.features.raw.wellknown.isSecureBackupRequired
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.raw.RawService
@ -100,7 +101,8 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
private val rawService: RawService,
private val session: Session,
private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider,
private val stringProvider: StringProvider
private val stringProvider: StringProvider,
private val matrix: Matrix,
) :
VectorViewModel<VerificationBottomSheetViewState, VerificationAction, VerificationBottomSheetViewEvents>(initialState),
VerificationService.Listener {
@ -402,7 +404,7 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
viewModelScope.launch(Dispatchers.IO) {
try {
action.cypherData.fromBase64().inputStream().use { ins ->
val res = session.secureStorageService().loadSecureSecret<Map<String, String>>(ins, action.alias)
val res = matrix.secureStorageService().loadSecureSecret<Map<String, String>>(ins, action.alias)
val trustResult = session.cryptoService().crossSigningService().checkTrustFromPrivateKeys(
res?.get(MASTER_KEY_SSSS_NAME),
res?.get(USER_SIGNING_KEY_SSSS_NAME),

View File

@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.room.RoomSortOrder
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@ -63,7 +64,9 @@ class ShortcutsHandler @Inject constructor(
// No op
return Job()
}
hasPinCode.set(pinCodeStore.getEncodedPin() != null)
coroutineScope.launch {
hasPinCode.set(pinCodeStore.hasEncodedPin())
}
val session = activeSessionHolder.getSafeActiveSession() ?: return Job()
return session.flow().liveRoomSummaries(
roomSummaryQueryParams {

View File

@ -40,7 +40,7 @@ import javax.inject.Singleton
*/
@Singleton
class NotificationDrawerManager @Inject constructor(
private val context: Context,
context: Context,
private val notificationDisplayer: NotificationDisplayer,
private val vectorPreferences: VectorPreferences,
private val activeSessionDataSource: ActiveSessionDataSource,
@ -72,7 +72,7 @@ class NotificationDrawerManager @Inject constructor(
}
private fun createInitialNotificationState(): NotificationState {
val queuedEvents = notificationEventPersistence.loadEvents(currentSession, factory = { rawEvents ->
val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents ->
NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25))
})
val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList()
@ -174,13 +174,13 @@ class NotificationDrawerManager @Inject constructor(
notificationState.clearAndAddRenderedEvents(eventsToRender)
val session = currentSession ?: return
renderEvents(session, eventsToRender)
persistEvents(session)
persistEvents()
}
}
private fun persistEvents(session: Session) {
private fun persistEvents() {
notificationState.queuedEvents { queuedEvents ->
notificationEventPersistence.persistEvents(queuedEvents, session)
notificationEventPersistence.persistEvents(queuedEvents)
}
}

View File

@ -17,7 +17,7 @@
package im.vector.app.features.notifications
import android.content.Context
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.Matrix
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
@ -27,14 +27,17 @@ import javax.inject.Inject
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache"
private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
class NotificationEventPersistence @Inject constructor(private val context: Context) {
class NotificationEventPersistence @Inject constructor(
private val context: Context,
private val matrix: Matrix,
) {
fun loadEvents(currentSession: Session?, factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
fun loadEvents(factory: (List<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = currentSession?.secureStorageService()?.loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
val events: ArrayList<NotifiableEvent>? = matrix.secureStorageService().loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) {
return factory(events)
}
@ -46,7 +49,7 @@ class NotificationEventPersistence @Inject constructor(private val context: Cont
return factory(emptyList())
}
fun persistEvents(queuedEvents: NotificationEventQueue, currentSession: Session) {
fun persistEvents(queuedEvents: NotificationEventQueue) {
if (queuedEvents.isEmpty()) {
deleteCachedRoomNotifications(context)
return
@ -55,7 +58,7 @@ class NotificationEventPersistence @Inject constructor(private val context: Cont
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile()
FileOutputStream(file).use {
currentSession.secureStorageService().securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
matrix.secureStorageService().securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it)
}
} catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info")

View File

@ -18,47 +18,38 @@ package im.vector.app.features.pin
import android.content.SharedPreferences
import androidx.core.content.edit
import com.beautycoder.pflockscreen.security.PFResult
import com.beautycoder.pflockscreen.security.PFSecurityManager
import com.beautycoder.pflockscreen.security.callbacks.PFPinCodeHelperCallback
import im.vector.app.features.pin.lockscreen.pincode.EncryptedPinCodeStorage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.extensions.orFalse
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
interface PinCodeStore {
suspend fun storeEncodedPin(encodePin: String)
suspend fun deleteEncodedPin()
fun getEncodedPin(): String?
suspend fun hasEncodedPin(): Boolean
fun getRemainingPinCodeAttemptsNumber(): Int
fun getRemainingBiometricsAttemptsNumber(): Int
interface PinCodeStore : EncryptedPinCodeStorage {
/**
* Will return the number of remaining attempts.
* Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time.
*/
fun getRemainingPinCodeAttemptsNumber(): Int
/**
* Should decrement the number of remaining PIN code attempts.
* @return The remaining attempts.
*/
fun onWrongPin(): Int
/**
* Will return the number of remaining attempts.
* Resets the counter of attempts for PIN code and biometric access.
*/
fun onWrongBiometrics(): Int
fun resetCounter()
/**
* Will reset the counters.
* Adds a listener to be notified when the PIN code us created or removed.
*/
fun resetCounters()
fun addListener(listener: PinCodeStoreListener)
/**
* Removes a listener to be notified when the PIN code us created or removed.
*/
fun removeListener(listener: PinCodeStoreListener)
}
@ -67,55 +58,41 @@ interface PinCodeStoreListener {
}
@Singleton
class SharedPrefPinCodeStore @Inject constructor(private val sharedPreferences: SharedPreferences) : PinCodeStore {
class SharedPrefPinCodeStore @Inject constructor(private val sharedPreferences: SharedPreferences) : PinCodeStore, EncryptedPinCodeStorage {
private val listeners = mutableSetOf<PinCodeStoreListener>()
override suspend fun storeEncodedPin(encodePin: String) {
override suspend fun getPinCode(): String? {
return sharedPreferences.getString(ENCODED_PIN_CODE_KEY, null)
}
override suspend fun savePinCode(pinCode: String) {
withContext(Dispatchers.IO) {
sharedPreferences.edit {
putString(ENCODED_PIN_CODE_KEY, encodePin)
putString(ENCODED_PIN_CODE_KEY, pinCode)
}
}
listeners.forEach { it.onPinSetUpChange(isConfigured = true) }
}
override suspend fun deleteEncodedPin() {
override suspend fun deletePinCode() {
withContext(Dispatchers.IO) {
// Also reset the counters
resetCounters()
resetCounter()
sharedPreferences.edit {
remove(ENCODED_PIN_CODE_KEY)
}
awaitPinCodeCallback<Boolean> {
PFSecurityManager.getInstance().pinCodeHelper.delete(it)
}
}
listeners.forEach { it.onPinSetUpChange(isConfigured = false) }
}
override fun getEncodedPin(): String? {
return sharedPreferences.getString(ENCODED_PIN_CODE_KEY, null)
}
override suspend fun hasEncodedPin(): Boolean = withContext(Dispatchers.IO) {
val hasEncodedPin = getEncodedPin()?.isNotBlank().orFalse()
if (!hasEncodedPin) {
return@withContext false
}
val result = awaitPinCodeCallback<Boolean> {
PFSecurityManager.getInstance().pinCodeHelper.isPinCodeEncryptionKeyExist(it)
}
result.error == null && result.result
override suspend fun hasEncodedPin(): Boolean {
return withContext(Dispatchers.IO) { sharedPreferences.contains(ENCODED_PIN_CODE_KEY) }
}
override fun getRemainingPinCodeAttemptsNumber(): Int {
return sharedPreferences.getInt(REMAINING_PIN_CODE_ATTEMPTS_KEY, MAX_PIN_CODE_ATTEMPTS_NUMBER_BEFORE_LOGOUT)
}
override fun getRemainingBiometricsAttemptsNumber(): Int {
return sharedPreferences.getInt(REMAINING_BIOMETRICS_ATTEMPTS_KEY, MAX_BIOMETRIC_ATTEMPTS_NUMBER_BEFORE_FORCE_PIN)
}
override fun onWrongPin(): Int {
val remaining = getRemainingPinCodeAttemptsNumber() - 1
sharedPreferences.edit {
@ -124,15 +101,7 @@ class SharedPrefPinCodeStore @Inject constructor(private val sharedPreferences:
return remaining
}
override fun onWrongBiometrics(): Int {
val remaining = getRemainingBiometricsAttemptsNumber() - 1
sharedPreferences.edit {
putInt(REMAINING_BIOMETRICS_ATTEMPTS_KEY, remaining)
}
return remaining
}
override fun resetCounters() {
override fun resetCounter() {
sharedPreferences.edit {
remove(REMAINING_PIN_CODE_ATTEMPTS_KEY)
remove(REMAINING_BIOMETRICS_ATTEMPTS_KEY)
@ -147,16 +116,11 @@ class SharedPrefPinCodeStore @Inject constructor(private val sharedPreferences:
listeners.remove(listener)
}
private suspend inline fun <T> awaitPinCodeCallback(crossinline callback: (PFPinCodeHelperCallback<T>) -> Unit) = suspendCoroutine<PFResult<T>> { cont ->
callback(PFPinCodeHelperCallback<T> { result -> cont.resume(result) })
}
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
private const val MAX_BIOMETRIC_ATTEMPTS_NUMBER_BEFORE_FORCE_PIN = 5
}
}

View File

@ -23,10 +23,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.args
import com.beautycoder.pflockscreen.PFFLockScreenConfiguration
import com.beautycoder.pflockscreen.fragments.PFLockScreenFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R
import im.vector.app.core.extensions.replaceFragment
@ -35,9 +32,15 @@ import im.vector.app.core.utils.toast
import im.vector.app.databinding.FragmentPinBinding
import im.vector.app.features.MainActivity
import im.vector.app.features.MainActivityArgs
import im.vector.app.features.pin.lockscreen.biometrics.BiometricAuthError
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguratorProvider
import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode
import im.vector.app.features.pin.lockscreen.ui.AuthMethod
import im.vector.app.features.pin.lockscreen.ui.LockScreenFragment
import im.vector.app.features.pin.lockscreen.ui.LockScreenListener
import im.vector.app.features.settings.VectorPreferences
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import javax.inject.Inject
@Parcelize
@ -47,7 +50,8 @@ data class PinArgs(
class PinFragment @Inject constructor(
private val pinCodeStore: PinCodeStore,
private val vectorPreferences: VectorPreferences
private val vectorPreferences: VectorPreferences,
private val configuratorProvider: LockScreenConfiguratorProvider,
) : VectorBaseFragment<FragmentPinBinding>() {
private val fragmentArgs: PinArgs by args()
@ -66,77 +70,81 @@ class PinFragment @Inject constructor(
}
private fun showCreateFragment() {
val createFragment = PFLockScreenFragment()
val builder = PFFLockScreenConfiguration.Builder(requireContext())
.setNewCodeValidation(true)
.setTitle(getString(R.string.create_pin_title))
.setNewCodeValidationTitle(getString(R.string.create_pin_confirm_title))
.setMode(PFFLockScreenConfiguration.MODE_CREATE)
createFragment.setConfiguration(builder.build())
createFragment.setCodeCreateListener(object : PFLockScreenFragment.OnPFLockScreenCodeCreateListener {
val createFragment = LockScreenFragment()
createFragment.lockScreenListener = object : LockScreenListener {
override fun onNewCodeValidationFailed() {
Toast.makeText(requireContext(), getString(R.string.create_pin_confirm_failure), Toast.LENGTH_SHORT).show()
}
override fun onPinCodeEnteredFirst(pinCode: String?): Boolean {
return false
override fun onPinCodeCreated() {
vectorBaseActivity.setResult(Activity.RESULT_OK)
vectorBaseActivity.finish()
}
}
override fun onCodeCreated(encodedCode: String) {
lifecycleScope.launch {
pinCodeStore.storeEncodedPin(encodedCode)
vectorBaseActivity.setResult(Activity.RESULT_OK)
vectorBaseActivity.finish()
}
}
})
configuratorProvider.updateDefaultConfiguration {
copy(
mode = LockScreenMode.CREATE,
title = getString(R.string.create_pin_title),
needsNewCodeValidation = true,
newCodeConfirmationTitle = getString(R.string.create_pin_confirm_title),
)
}
replaceFragment(R.id.pinFragmentContainer, createFragment)
}
private fun showAuthFragment() {
val encodedPin = pinCodeStore.getEncodedPin() ?: return
val authFragment = PFLockScreenFragment()
val canUseBiometrics = pinCodeStore.getRemainingBiometricsAttemptsNumber() > 0
val builder = PFFLockScreenConfiguration.Builder(requireContext())
.setAutoShowBiometric(true)
.setUseBiometric(vectorPreferences.useBiometricsToUnlock() && canUseBiometrics)
.setAutoShowBiometric(canUseBiometrics)
.setTitle(getString(R.string.auth_pin_title))
.setLeftButton(getString(R.string.auth_pin_forgot))
.setClearCodeOnError(true)
.setMode(PFFLockScreenConfiguration.MODE_AUTH)
authFragment.setConfiguration(builder.build())
authFragment.setEncodedPinCode(encodedPin)
authFragment.setOnLeftButtonClickListener {
displayForgotPinWarningDialog()
}
authFragment.setLoginListener(object : PFLockScreenFragment.OnPFLockScreenLoginListener {
override fun onPinLoginFailed() {
onWrongPin()
}
override fun onBiometricAuthSuccessful() {
pinCodeStore.resetCounters()
vectorBaseActivity.setResult(Activity.RESULT_OK)
vectorBaseActivity.finish()
}
override fun onBiometricAuthLoginFailed() {
val remainingAttempts = pinCodeStore.onWrongBiometrics()
if (remainingAttempts <= 0) {
// Disable Biometrics
builder.setUseBiometric(false)
authFragment.setConfiguration(builder.build())
val authFragment = LockScreenFragment()
val canUseBiometrics = vectorPreferences.useBiometricsToUnlock()
authFragment.onLeftButtonClickedListener = View.OnClickListener { displayForgotPinWarningDialog() }
authFragment.lockScreenListener = object : LockScreenListener {
override fun onAuthenticationFailure(authMethod: AuthMethod) {
when (authMethod) {
AuthMethod.PIN_CODE -> onWrongPin()
AuthMethod.BIOMETRICS -> Unit
}
}
override fun onCodeInputSuccessful() {
pinCodeStore.resetCounters()
override fun onAuthenticationSuccess(authMethod: AuthMethod) {
pinCodeStore.resetCounter()
vectorBaseActivity.setResult(Activity.RESULT_OK)
vectorBaseActivity.finish()
}
})
override fun onAuthenticationError(authMethod: AuthMethod, throwable: Throwable) {
if (throwable is BiometricAuthError) {
// System disabled biometric auth, no need to do it ourselves
if (throwable.isAuthPermanentlyDisabledError) {
vectorPreferences.setUseBiometricToUnlock(false)
}
Toast.makeText(requireContext(), throwable.localizedMessage, Toast.LENGTH_SHORT).show()
} else {
Timber.e(throwable)
}
}
override fun onBiometricKeyInvalidated() {
// Disable biometric auth in settings and remove system key
vectorPreferences.setUseBiometricToUnlock(false)
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.auth_biometric_key_invalidated_message)
.setPositiveButton(R.string.ok, null)
.show()
}
}
configuratorProvider.updateDefaultConfiguration {
copy(
mode = LockScreenMode.VERIFY,
title = getString(R.string.auth_pin_title),
isStrongBiometricsEnabled = isStrongBiometricsEnabled && canUseBiometrics,
isWeakBiometricsEnabled = isWeakBiometricsEnabled && canUseBiometrics,
isDeviceCredentialUnlockEnabled = isDeviceCredentialUnlockEnabled && canUseBiometrics,
autoStartBiometric = canUseBiometrics,
leftButtonTitle = getString(R.string.auth_pin_forgot),
clearCodeOnError = true,
)
}
replaceFragment(R.id.pinFragmentContainer, authFragment)
}
@ -150,7 +158,7 @@ class PinFragment @Inject constructor(
else -> {
requireActivity().toast(R.string.too_many_pin_failures)
// Logout
MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCredentials = true))
launchResetPinFlow()
}
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.biometrics
import androidx.biometric.BiometricPrompt
/**
* Wrapper for [BiometricPrompt.AuthenticationCallback] errors.
*/
class BiometricAuthError(val code: Int, message: String) : Throwable(message) {
/**
* This error disables Biometric authentication, either temporarily or permanently.
*/
val isAuthDisabledError: Boolean get() = code in LOCKOUT_ERROR_CODES
/**
* This error permanently disables Biometric authentication.
*/
val isAuthPermanentlyDisabledError: Boolean get() = code == BiometricPrompt.ERROR_LOCKOUT_PERMANENT
companion object {
private val LOCKOUT_ERROR_CODES = arrayOf(BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT)
}
}

View File

@ -0,0 +1,323 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.biometrics
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import androidx.annotation.MainThread
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import im.vector.app.R
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguratorProvider
import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository
import im.vector.app.features.pin.lockscreen.ui.fallbackprompt.FallbackBiometricDialogFragment
import im.vector.app.features.pin.lockscreen.utils.DevicePromptCheck
import im.vector.app.features.pin.lockscreen.utils.hasFlag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
import java.security.KeyStore
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext
/**
* This is a helper to manage system authentication (biometric and other types) and the system key.
*/
class BiometricHelper @Inject constructor(
@ApplicationContext private val context: Context,
private val lockScreenKeyRepository: LockScreenKeyRepository,
private val configurationProvider: LockScreenConfiguratorProvider,
private val biometricManager: BiometricManager,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) {
private var prompt: BiometricPrompt? = null
private val configuration: LockScreenConfiguration get() = configurationProvider.currentConfiguration
/**
* Returns true if a weak biometric method (i.e.: some face or iris unlock implementations) can be used.
*/
val canUseWeakBiometricAuth: Boolean get() =
configuration.isWeakBiometricsEnabled && biometricManager.canAuthenticate(BIOMETRIC_WEAK) == BIOMETRIC_SUCCESS
/**
* Returns true if a strong biometric method (i.e.: fingerprint, some face or iris unlock implementations) can be used.
*/
val canUseStrongBiometricAuth: Boolean get() =
configuration.isStrongBiometricsEnabled && biometricManager.canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS
/**
* Returns true if the device credentials can be used to unlock (system pin code, password, pattern, etc.).
*/
val canUseDeviceCredentialsAuth: Boolean get() =
configuration.isDeviceCredentialUnlockEnabled && biometricManager.canAuthenticate(DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS
/**
* Returns true if any system authentication method (biometric weak/strong or device credentials) can be used.
*/
@VisibleForTesting(otherwise = PRIVATE)
internal val canUseAnySystemAuth: Boolean get() = canUseWeakBiometricAuth || canUseStrongBiometricAuth || canUseDeviceCredentialsAuth
/**
* Returns true if any system authentication method and there is a valid associated key.
*/
val isSystemAuthEnabledAndValid: Boolean get() = canUseAnySystemAuth && isSystemKeyValid
/**
* Returns true is the [KeyStore] contains a key associated to system authentication.
*/
val hasSystemKey: Boolean get() = lockScreenKeyRepository.hasSystemKey()
/**
* Returns true if the system key is valid, that is, not invalidated by new enrollments.
*/
val isSystemKeyValid: Boolean get() = lockScreenKeyRepository.isSystemKeyValid()
/**
* Enables system authentication after displaying a [BiometricPrompt] in the passed [FragmentActivity].
* Note: Must be called from the Main thread.
* @return: A [Flow] with the [Boolean] success/failure result or a [BiometricAuthError].
*/
@MainThread
fun enableAuthentication(activity: FragmentActivity): Flow<Boolean> {
return authenticateInternal(activity, checkSystemKeyExists = false, cryptoObject = null)
}
/**
* Disables system authentication cancelling the current [BiometricPrompt] if needed.
* Note: Must be called from the Main thread.
*/
@MainThread
fun disableAuthentication() {
lockScreenKeyRepository.deleteSystemKey()
cancelPrompt()
}
/**
* Displays a [BiometricPrompt] in the passed [FragmentActivity] and unlocking the system key if succeeds.
* Note: Must be called from the Main thread.
* @return: A [Flow] with the [Boolean] success/failure result or a [BiometricAuthError].
*/
@MainThread
fun authenticate(activity: FragmentActivity): Flow<Boolean> {
return authenticateInternal(activity, checkSystemKeyExists = true, cryptoObject = null)
}
/**
* Displays a [BiometricPrompt] in the passed [Fragment] and unlocking the system key if succeeds.
* Note: Must be called from the Main thread.
* @return: A [Flow] with the [Boolean] success/failure result or a [BiometricAuthError].
*/
@MainThread
fun authenticate(fragment: Fragment): Flow<Boolean> {
val activity = fragment.activity ?: return flowOf(false)
return authenticate(activity)
}
@SuppressLint("NewApi")
@OptIn(ExperimentalCoroutinesApi::class)
private fun authenticateInternal(
activity: FragmentActivity,
checkSystemKeyExists: Boolean,
cryptoObject: BiometricPrompt.CryptoObject? = null,
): Flow<Boolean> {
if (checkSystemKeyExists && !isSystemAuthEnabledAndValid) return flowOf(false)
if (prompt != null) {
cancelPrompt()
}
val channel = createAuthChannel()
prompt = authenticateWithPromptInternal(activity, cryptoObject, channel)
return flow {
// We need to listen to the channel until it's closed
while (!channel.isClosedForReceive) {
val result = channel.receiveCatching()
when (val exception = result.exceptionOrNull()) {
null -> result.getOrNull()?.let { emit(it) }
else -> {
// Exception found, stop collecting, throw it and remove the prompt reference
prompt = null
throw exception
}
}
}
// Generates the system key on successful authentication
if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M) {
lockScreenKeyRepository.ensureSystemKey()
}
// Channel is closed, remove prompt reference
prompt = null
}
}
@VisibleForTesting(otherwise = PRIVATE)
internal fun authenticateWithPromptInternal(
activity: FragmentActivity,
cryptoObject: BiometricPrompt.CryptoObject? = null,
channel: Channel<Boolean>,
): BiometricPrompt {
val executor = ContextCompat.getMainExecutor(context)
val callback = createSuspendingAuthCallback(channel, executor.asCoroutineDispatcher())
val authenticators = getAvailableAuthenticators()
val isUsingDeviceCredentialAuthenticator = authenticators.hasFlag(DEVICE_CREDENTIAL)
val cancelButtonTitle = configuration.biometricCancelButtonTitle ?: context.getString(R.string.lockscreen_cancel)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(configuration.biometricTitle ?: context.getString(R.string.lockscreen_sign_in))
.apply {
configuration.biometricSubtitle?.let {
setSubtitle(it)
}
if (!isUsingDeviceCredentialAuthenticator) {
setNegativeButtonText(cancelButtonTitle)
}
}
.setAllowedAuthenticators(authenticators)
.build()
return BiometricPrompt(activity, executor, callback).also {
showFallbackFragmentIfNeeded(activity, channel.receiveAsFlow(), executor.asCoroutineDispatcher()) {
// For some reason this seems to be needed unless we want to receive a fragment transaction exception
delay(1L)
if (cryptoObject != null) {
it.authenticate(promptInfo, cryptoObject)
} else {
it.authenticate(promptInfo)
}
}
}
}
private fun getAvailableAuthenticators(): Int {
var authenticators = 0
// Android 10 (Q) and below can only use a single authenticator at the same time
if (buildVersionSdkIntProvider.get() <= Build.VERSION_CODES.Q) {
authenticators = when {
canUseStrongBiometricAuth -> BIOMETRIC_STRONG
canUseWeakBiometricAuth -> BIOMETRIC_WEAK
canUseDeviceCredentialsAuth -> DEVICE_CREDENTIAL
else -> 0
}
} else {
if (canUseDeviceCredentialsAuth) {
authenticators += DEVICE_CREDENTIAL
}
if (canUseStrongBiometricAuth) {
authenticators += BIOMETRIC_STRONG
}
// We can't use BIOMETRIC_STRONG and BIOMETRIC_WEAK at the same time. We should prioritize BIOMETRIC_STRONG.
if (!authenticators.hasFlag(BIOMETRIC_STRONG) && canUseWeakBiometricAuth) {
authenticators += BIOMETRIC_WEAK
}
}
return authenticators
}
private fun createSuspendingAuthCallback(
channel: Channel<Boolean>,
coroutineContext: CoroutineContext,
): BiometricPrompt.AuthenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
private val scope = CoroutineScope(coroutineContext)
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
scope.launch {
// Error is a terminal event, should close both the Channel and the CoroutineScope to free resources.
channel.close(BiometricAuthError(errorCode, errString.toString()))
scope.cancel()
}
}
override fun onAuthenticationFailed() {
scope.launch { channel.send(false) }
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
scope.launch {
channel.send(true)
// Success is a terminal event, should close both the Channel and the CoroutineScope to free resources.
channel.close()
scope.cancel()
}
}
}
/**
* This method displays a fallback biometric prompt dialog for devices with issues with their system implementations.
* @param activity [FragmentActivity] to display this fallback fragment in.
* @param authenticationFLow [Flow] where the authentication events will be received.
* @param coroutineContext [CoroutineContext] to run async code. It's shared with the [BiometricPrompt] executor value.
* @param showPrompt Lambda containing the code to show the original [BiometricPrompt] above the fallback dialog.
* @see [DevicePromptCheck].
*/
private fun showFallbackFragmentIfNeeded(
activity: FragmentActivity,
authenticationFLow: Flow<Boolean>,
coroutineContext: CoroutineContext,
showPrompt: suspend () -> Unit
) {
val scope = CoroutineScope(coroutineContext)
if (DevicePromptCheck.isDeviceWithNoBiometricUI) {
val fallbackFragment = activity.supportFragmentManager.findFragmentByTag(FALLBACK_BIOMETRIC_FRAGMENT_TAG) as? FallbackBiometricDialogFragment
?: FallbackBiometricDialogFragment.instantiate(
title = configuration.biometricTitle,
description = configuration.biometricSubtitle,
cancelActionText = configuration.biometricCancelButtonTitle,
)
fallbackFragment.onDismiss = { cancelPrompt() }
fallbackFragment.authenticationFlow = authenticationFLow
activity.supportFragmentManager.beginTransaction()
.runOnCommit { scope.launch { showPrompt() } }
.apply { fallbackFragment.show(this, FALLBACK_BIOMETRIC_FRAGMENT_TAG) }
} else {
scope.launch { showPrompt() }
}
}
@VisibleForTesting(otherwise = PRIVATE)
internal fun cancelPrompt() {
prompt?.cancelAuthentication()
prompt = null
}
@VisibleForTesting(otherwise = PRIVATE)
internal fun createAuthChannel(): Channel<Boolean> = Channel(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
companion object {
private const val FALLBACK_BIOMETRIC_FRAGMENT_TAG = "fragment.biometric_fallback"
}
}

View File

@ -0,0 +1,59 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.configuration
/**
* Configuration to be used by the lockscreen feature.
*/
data class LockScreenConfiguration(
/** Which mode should the UI display, [LockScreenMode.VERIFY] or [LockScreenMode.CREATE]. */
val mode: LockScreenMode,
/** Length in digits of the pin code. */
val pinCodeLength: Int,
/** Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported. */
val isStrongBiometricsEnabled: Boolean,
/** Authentication with weak methods (most face/iris unlock implementations) is supported. */
val isWeakBiometricsEnabled: Boolean,
/** Authentication with device credentials (system lockscreen pin code, password, pattern) is supported. */
val isDeviceCredentialUnlockEnabled: Boolean,
/** New pin code creation needs to be inputted twice for confirmation. */
val needsNewCodeValidation: Boolean,
/** Biometric authentication should be started automatically when the pin code screen is displayed. Defaults to true. */
val autoStartBiometric: Boolean = true,
/** Display a button in the bottom-left corner of the 'pin pad'. Defaults to true. */
val leftButtonVisible: Boolean = true,
/** Text of the button in the bottom-left corner of the 'pin pad'. Optional. */
val leftButtonTitle: String? = null,
/** Title of the pin code screen. Optional. */
val title: String? = null,
/** Subtitle of the pin code screen. Optional. */
val subtitle: String? = null,
/** Title of the 'confirm pin code' screen. Optional. */
val newCodeConfirmationTitle: String? = null,
/** Clear the inputted pin code on error. Defaults to true. */
val clearCodeOnError: Boolean = true,
/** Vibrate on authentication failed. Defaults to true. */
val vibrateOnError: Boolean = true,
/** Animated the pin code view on authentication failed. Defaults to true. */
val animateOnError: Boolean = true,
/** Title for the Biometric prompt dialog. Optional. */
val biometricTitle: String? = null,
/** Subtitle for the Biometric prompt dialog. Optional. */
val biometricSubtitle: String? = null,
/** Text for the cancel button of the Biometric prompt dialog. Optional. */
val biometricCancelButtonTitle: String? = null,
)

View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.configuration
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Class used to hold both the [defaultConfiguration] and an updated version in [currentConfiguration].
*/
@Singleton
class LockScreenConfiguratorProvider @Inject constructor(
/** Default [LockScreenConfiguration], any derived configuration created using [updateDefaultConfiguration] will use this as a base. */
val defaultConfiguration: LockScreenConfiguration,
) {
private val mutableConfigurationFlow = MutableStateFlow(defaultConfiguration)
/**
* A [Flow] that emits any changes in configuration.
*/
val configurationFlow: Flow<LockScreenConfiguration> = mutableConfigurationFlow
/**
* The current configuration to be read and used.
*/
val currentConfiguration get() = mutableConfigurationFlow.value
/**
* Applies the changes in [block] to the [defaultConfiguration] to generate a new [currentConfiguration].
*/
fun updateDefaultConfiguration(block: LockScreenConfiguration.() -> LockScreenConfiguration) {
mutableConfigurationFlow.value = defaultConfiguration.block()
}
/**
* Resets the [currentConfiguration] to the [defaultConfiguration].
*/
fun reset() {
mutableConfigurationFlow.value = defaultConfiguration
}
}

View File

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.configuration
import android.os.Parcelable
import im.vector.app.features.pin.lockscreen.ui.LockScreenViewModel
import kotlinx.parcelize.Parcelize
/**
* Mode used by [LockScreenViewModel] to configure the UI and interactions.
*/
@Parcelize
enum class LockScreenMode : Parcelable {
CREATE,
VERIFY
}

View File

@ -0,0 +1,142 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.crypto
import android.annotation.SuppressLint
import android.content.Context
import android.hardware.biometrics.BiometricPrompt
import android.os.Build
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.util.Base64
import androidx.annotation.RequiresApi
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import org.matrix.android.sdk.api.securestorage.SecretStoringUtils
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
import java.security.Key
import java.security.KeyStore
/**
* Wrapper class to make working with KeyStore and keys easier.
*/
class KeyStoreCrypto @AssistedInject constructor(
@Assisted val alias: String,
@Assisted keyNeedsUserAuthentication: Boolean,
context: Context,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
private val keyStore: KeyStore,
// It's easier to test it this way
private val secretStoringUtils: SecretStoringUtils = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider, keyNeedsUserAuthentication)
) {
@AssistedFactory
interface Factory {
fun provide(alias: String, keyNeedsUserAuthentication: Boolean): KeyStoreCrypto
}
/**
* Ensures a [Key] for the [alias] exists and validates it.
* @throws KeyPermanentlyInvalidatedException if key is not valid.
*/
@SuppressLint("NewApi")
@Throws(KeyPermanentlyInvalidatedException::class)
fun ensureKey() = secretStoringUtils.ensureKey(alias).also {
// Check validity of Key by initializing an encryption Cipher
secretStoringUtils.getEncryptCipher(alias)
}
/**
* Encrypts the [ByteArray] value passed using generated the crypto key.
*/
fun encrypt(value: ByteArray): ByteArray = secretStoringUtils.securelyStoreBytes(value, alias)
/**
* Encrypts the [String] value passed using generated the crypto key.
*/
fun encrypt(value: String): ByteArray = encrypt(value.toByteArray())
/**
* Encrypts the [ByteArray] value passed using generated the crypto key.
* @return A Base64 encoded String.
*/
fun encryptToString(value: ByteArray): String = Base64.encodeToString(encrypt(value), Base64.NO_WRAP)
/**
* Encrypts the [String] value passed using generated the crypto key.
* @return A Base64 encoded String.
*/
fun encryptToString(value: String): String = Base64.encodeToString(encrypt(value), Base64.NO_WRAP)
/**
* Decrypts the [ByteArray] value passed using the generated crypto key.
*/
fun decrypt(value: ByteArray): ByteArray = secretStoringUtils.loadSecureSecretBytes(value, alias)
/**
* Decrypts the [String] value passed using the generated crypto key.
*/
fun decrypt(value: String): ByteArray = decrypt(Base64.decode(value, Base64.NO_WRAP))
/**
* Decrypts the [ByteArray] value passed using the generated crypto key.
* @return The decrypted contents in as a String.
*/
fun decryptToString(value: ByteArray): String = String(decrypt(value))
/**
* Decrypts the [String] value passed using the generated crypto key.
* @return The decrypted contents in as a String.
*/
fun decryptToString(value: String): String = String(decrypt(value))
/**
* Check if the key associated with the [alias] is valid.
*/
@SuppressLint("NewApi")
fun hasValidKey(): Boolean {
val keyExists = hasKey()
return if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M && keyExists) {
try {
ensureKey()
true
} catch (e: KeyPermanentlyInvalidatedException) {
false
}
} else {
keyExists
}
}
/**
* Check if the key associated with the [alias] is stored in the [KeyStore].
*/
fun hasKey(): Boolean = keyStore.containsAlias(alias)
/**
* Deletes the key associated with the [alias] from the [KeyStore].
*/
fun deleteKey() = secretStoringUtils.safeDeleteKey(alias)
/**
* Creates a [BiometricPrompt.CryptoObject] to be used in authentication.
* @throws KeyPermanentlyInvalidatedException if key is invalidated.
*/
@Throws(KeyPermanentlyInvalidatedException::class)
@RequiresApi(Build.VERSION_CODES.P)
fun getCryptoObject() = BiometricPrompt.CryptoObject(secretStoringUtils.getEncryptCipher(alias))
}

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.crypto
object LockScreenCryptoConstants {
const val ANDROID_KEY_STORE = "AndroidKeyStore"
const val LEGACY_PIN_CODE_KEY_ALIAS = "fp_pin_lock_screen_key_store"
}

View File

@ -0,0 +1,104 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.crypto
import android.os.Build
import android.security.keystore.KeyPermanentlyInvalidatedException
import androidx.annotation.RequiresApi
import im.vector.app.features.settings.VectorPreferences
import timber.log.Timber
/**
* Class in charge of managing both the PIN code key and the system authentication keys.
*/
class LockScreenKeyRepository(
baseName: String,
private val pinCodeMigrator: PinCodeMigrator,
private val vectorPreferences: VectorPreferences,
private val keyStoreCryptoFactory: KeyStoreCrypto.Factory,
) {
private val pinCodeKeyAlias = "$baseName.pin_code"
private val systemKeyAlias = "$baseName.system"
private val pinCodeCrypto: KeyStoreCrypto by lazy {
keyStoreCryptoFactory.provide(pinCodeKeyAlias, keyNeedsUserAuthentication = false)
}
private val systemKeyCrypto: KeyStoreCrypto by lazy {
keyStoreCryptoFactory.provide(systemKeyAlias, keyNeedsUserAuthentication = true)
}
/**
* Encrypts the [pinCode], creating the associated key if needed.
*/
fun encryptPinCode(pinCode: String): String = pinCodeCrypto.encryptToString(pinCode)
/**
* Decrypts the [encodedPinCode] into a plain [String] or null.
*/
fun decryptPinCode(encodedPinCode: String): String = pinCodeCrypto.decryptToString(encodedPinCode)
/**
* Get the key associated to the system authentication (biometrics). It will be created if it didn't exist before.
* Note: this key will be invalidated by new biometric enrollments.
* @throws KeyPermanentlyInvalidatedException if key is invalidated.
*/
@RequiresApi(Build.VERSION_CODES.M)
fun ensureSystemKey() = systemKeyCrypto.ensureKey()
/**
* Returns if the PIN code key already exists.
*/
fun hasPinCodeKey() = pinCodeCrypto.hasKey()
/**
* Returns if the system authentication key already exists.
*/
fun hasSystemKey() = systemKeyCrypto.hasKey()
/**
* Deletes the PIN code key from the KeyStore.
*/
fun deletePinCodeKey() = pinCodeCrypto.deleteKey()
/**
* Deletes the system authentication key from the KeyStore.
*/
fun deleteSystemKey() = systemKeyCrypto.deleteKey()
/**
* Checks if the current system authentication key exists and is valid.
*/
fun isSystemKeyValid() = systemKeyCrypto.hasValidKey()
/**
* Migrates the PIN code key and encrypted value to use a more secure cipher, also creates a new system key if needed.
*/
suspend fun migrateKeysIfNeeded() {
if (pinCodeMigrator.isMigrationNeeded()) {
pinCodeMigrator.migrate(pinCodeKeyAlias)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && vectorPreferences.useBiometricsToUnlock()) {
try {
ensureSystemKey()
} catch (e: KeyPermanentlyInvalidatedException) {
Timber.e("Could not automatically create biometric key.", e)
}
}
}
}
}

View File

@ -0,0 +1,84 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.crypto
import android.os.Build
import android.util.Base64
import androidx.annotation.VisibleForTesting
import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.lockscreen.crypto.LockScreenCryptoConstants.LEGACY_PIN_CODE_KEY_ALIAS
import org.matrix.android.sdk.api.securestorage.SecretStoringUtils
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
import java.security.Key
import java.security.KeyStore
import javax.crypto.Cipher
import javax.inject.Inject
/**
* Used to migrate from the old PIN code key ciphers to a more secure ones.
*/
class PinCodeMigrator @Inject constructor(
private val pinCodeStore: PinCodeStore,
private val keyStore: KeyStore,
private val secretStoringUtils: SecretStoringUtils,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) {
private val legacyKey: Key get() = keyStore.getKey(LEGACY_PIN_CODE_KEY_ALIAS, null)
/**
* Migrates from the old ciphers and [LEGACY_PIN_CODE_KEY_ALIAS] to the [newAlias].
*/
suspend fun migrate(newAlias: String) {
if (!keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS)) return
val pinCode = getDecryptedPinCode() ?: return
val encryptedBytes = secretStoringUtils.securelyStoreBytes(pinCode.toByteArray(), newAlias)
val encryptedPinCode = Base64.encodeToString(encryptedBytes, Base64.NO_WRAP)
pinCodeStore.savePinCode(encryptedPinCode)
keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS)
}
fun isMigrationNeeded(): Boolean = keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal suspend fun getDecryptedPinCode(): String? {
val encryptedPinCode = pinCodeStore.getPinCode() ?: return null
val cipher = getDecodeCipher()
val bytes = cipher.doFinal(Base64.decode(encryptedPinCode, Base64.NO_WRAP))
return String(bytes)
}
private fun getDecodeCipher(): Cipher {
return when (buildVersionSdkIntProvider.get()) {
Build.VERSION_CODES.LOLLIPOP, Build.VERSION_CODES.LOLLIPOP_MR1 -> getCipherL()
else -> getCipherM()
}.also { it.init(Cipher.DECRYPT_MODE, legacyKey) }
}
private fun getCipherL(): Cipher {
// We cannot mock this in tests as it's tied to the actual cryptographic implementation of the OS version
val provider = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) "AndroidOpenSSL" else "AndroidKeyStoreBCWorkaround"
val transformation = "RSA/ECB/PKCS1Padding"
return Cipher.getInstance(transformation, provider)
}
private fun getCipherM(): Cipher {
val transformation = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
return Cipher.getInstance(transformation)
}
}

View File

@ -0,0 +1,100 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.di
import android.content.Context
import androidx.biometric.BiometricManager
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoMap
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.MavericksViewModelKey
import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration
import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode
import im.vector.app.features.pin.lockscreen.crypto.KeyStoreCrypto
import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository
import im.vector.app.features.pin.lockscreen.crypto.PinCodeMigrator
import im.vector.app.features.pin.lockscreen.pincode.EncryptedPinCodeStorage
import im.vector.app.features.pin.lockscreen.ui.LockScreenViewModel
import im.vector.app.features.settings.VectorPreferences
import org.matrix.android.sdk.api.securestorage.SecretStoringUtils
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider
import java.security.KeyStore
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object LockScreenModule {
@Provides
fun provideKeyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
@Provides
fun provideBuildVersionSdkIntProvider(): BuildVersionSdkIntProvider = DefaultBuildVersionSdkIntProvider()
@Provides
fun provideSecretStoringUtils(
@ApplicationContext context: Context,
keyStore: KeyStore,
buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider)
@Provides
fun provideLockScreenConfig() = LockScreenConfiguration(
mode = LockScreenMode.VERIFY,
pinCodeLength = 4,
isWeakBiometricsEnabled = false,
isDeviceCredentialUnlockEnabled = false,
isStrongBiometricsEnabled = true,
needsNewCodeValidation = true,
)
@Provides
@Singleton
fun provideKeyRepository(
pinCodeMigrator: PinCodeMigrator,
vectorPreferences: VectorPreferences,
keyStoreCryptoFactory: KeyStoreCrypto.Factory,
) = LockScreenKeyRepository(
baseName = "vector",
pinCodeMigrator,
vectorPreferences,
keyStoreCryptoFactory,
)
@Provides
fun provideBiometricManager(@ApplicationContext context: Context) = BiometricManager.from(context)
}
@Module
@InstallIn(SingletonComponent::class)
interface LockScreenBindsModule {
@Binds
@IntoMap
@MavericksViewModelKey(LockScreenViewModel::class)
fun bindLockScreenViewModel(factory: LockScreenViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
fun bindSharedPreferencesStorage(pinCodeStore: PinCodeStore): EncryptedPinCodeStorage
}

View File

@ -0,0 +1,43 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.pincode
/**
* 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 encrypted PIN code is stored or not.
*/
suspend fun hasEncodedPin(): Boolean
}

View File

@ -0,0 +1,65 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.pincode
import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository
import javax.inject.Inject
/**
* A helper class to manage the PIN code creation, verification, removal and migration.
*/
class PinCodeHelper @Inject constructor(
private val lockScreenKeyRepository: LockScreenKeyRepository,
private val encryptedStorage: EncryptedPinCodeStorage,
) {
/**
* Returns if PIN code is available (both the key exists and the encrypted value is stored).
*/
suspend fun isPinCodeAvailable() = lockScreenKeyRepository.hasPinCodeKey() && encryptedStorage.getPinCode() != null
/**
* Creates a PIN code key if needed and stores the PIN code encrypted with it.
*/
suspend fun createPinCode(pinCode: String) {
val encryptedValue = lockScreenKeyRepository.encryptPinCode(pinCode)
encryptedStorage.savePinCode(encryptedValue)
}
/**
* Verifies the passed [pinCode] against the encrypted one.
*/
suspend fun verifyPinCode(pinCode: String): Boolean {
val encryptedPinCode = encryptedStorage.getPinCode() ?: return false
return lockScreenKeyRepository.decryptPinCode(encryptedPinCode) == pinCode
}
/**
* Deletes the store PIN code as well as the associated key.
*/
suspend fun deletePinCode() {
encryptedStorage.deletePinCode()
lockScreenKeyRepository.deletePinCodeKey()
}
/**
* Migrates the PIN code key and encrypted value to use a more secure cipher.
*/
suspend fun migratePinCodeIfNeeded() {
lockScreenKeyRepository.migrateKeysIfNeeded()
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui
import androidx.fragment.app.FragmentActivity
import im.vector.app.core.platform.VectorViewModelAction
sealed class LockScreenAction : VectorViewModelAction {
data class PinCodeEntered(val value: String) : LockScreenAction()
data class ShowBiometricPrompt(val callingActivity: FragmentActivity) : LockScreenAction()
}

View File

@ -0,0 +1,207 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.hardware.vibrate
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentLockScreenBinding
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration
import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode
import im.vector.app.features.pin.lockscreen.views.LockScreenCodeView
@AndroidEntryPoint
class LockScreenFragment : VectorBaseFragment<FragmentLockScreenBinding>() {
var lockScreenListener: LockScreenListener? = null
var onLeftButtonClickedListener: View.OnClickListener? = null
private val viewModel: LockScreenViewModel by fragmentViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLockScreenBinding =
FragmentLockScreenBinding.inflate(layoutInflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupBindings(views)
viewModel.observeViewEvents {
handleEvent(it)
}
withState(viewModel) { state ->
if (state.lockScreenConfiguration.mode == LockScreenMode.CREATE) return@withState
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
if (state.isBiometricKeyInvalidated) {
lockScreenListener?.onBiometricKeyInvalidated()
} else if (state.showBiometricPromptAutomatically) {
showBiometricPrompt()
}
}
}
}
override fun onDestroy() {
super.onDestroy()
viewModel.reset()
}
override fun invalidate() = withState(viewModel) { state ->
when (state.pinCodeState) {
is PinCodeState.FirstCodeEntered -> {
setupTitleView(views.titleTextView, true, state.lockScreenConfiguration)
lockScreenListener?.onFirstCodeEntered()
}
is PinCodeState.Idle -> {
setupTitleView(views.titleTextView, false, state.lockScreenConfiguration)
}
}
renderDeleteOrFingerprintButtons(views, views.codeView.enteredDigits)
}
private fun onAuthFailure(method: AuthMethod) {
lockScreenListener?.onAuthenticationFailure(method)
val configuration = withState(viewModel) { it.lockScreenConfiguration }
if (configuration.vibrateOnError) {
vibrate(requireContext(), 400)
}
if (configuration.animateOnError) {
context?.let {
val animation = AnimationUtils.loadAnimation(it, R.anim.lockscreen_shake_animation)
views.codeView.startAnimation(animation)
}
}
}
private fun onAuthError(authMethod: AuthMethod, throwable: Throwable) {
lockScreenListener?.onAuthenticationError(authMethod, throwable)
withState(viewModel) { state ->
if (state.lockScreenConfiguration.clearCodeOnError) {
views.codeView.clearCode()
}
}
}
private fun handleEvent(viewEvent: LockScreenViewEvent) {
when (viewEvent) {
is LockScreenViewEvent.CodeCreationComplete -> lockScreenListener?.onPinCodeCreated()
is LockScreenViewEvent.ClearPinCode -> {
if (viewEvent.confirmationFailed) {
lockScreenListener?.onNewCodeValidationFailed()
}
views.codeView.clearCode()
}
is LockScreenViewEvent.AuthSuccessful -> lockScreenListener?.onAuthenticationSuccess(viewEvent.method)
is LockScreenViewEvent.AuthFailure -> onAuthFailure(viewEvent.method)
is LockScreenViewEvent.AuthError -> onAuthError(viewEvent.method, viewEvent.throwable)
}
}
private fun setupBindings(binding: FragmentLockScreenBinding) = with(binding) {
val configuration = withState(viewModel) { it.lockScreenConfiguration }
val lockScreenMode = configuration.mode
configuration.title?.let { titleTextView.text = it }
configuration.subtitle?.let {
subtitleTextView.text = it
subtitleTextView.isVisible = true
}
setupTitleView(titleTextView, false, configuration)
setupCodeView(codeView, configuration)
setupCodeButton('0', button0, this)
setupCodeButton('1', button1, this)
setupCodeButton('2', button2, this)
setupCodeButton('3', button3, this)
setupCodeButton('4', button4, this)
setupCodeButton('5', button5, this)
setupCodeButton('6', button6, this)
setupCodeButton('7', button7, this)
setupCodeButton('8', button8, this)
setupCodeButton('9', button9, this)
setupDeleteButton(buttonDelete, this)
setupFingerprintButton(buttonFingerPrint)
setupLeftButton(buttonLeft, lockScreenMode, configuration)
renderDeleteOrFingerprintButtons(this, 0)
}
private fun setupTitleView(titleView: TextView, isConfirmation: Boolean, configuration: LockScreenConfiguration) = with(titleView) {
text = if (isConfirmation) {
configuration.newCodeConfirmationTitle ?: getString(R.string.lockscreen_confirm_pin)
} else {
configuration.title ?: getString(R.string.lockscreen_title)
}
}
private fun setupCodeView(lockScreenCodeView: LockScreenCodeView, configuration: LockScreenConfiguration) = with(lockScreenCodeView) {
codeLength = configuration.pinCodeLength
onCodeCompleted = LockScreenCodeView.CodeCompletedListener { code ->
viewModel.handle(LockScreenAction.PinCodeEntered(code))
}
}
private fun setupCodeButton(value: Char, view: View, binding: FragmentLockScreenBinding) {
view.setOnClickListener {
val size = binding.codeView.onCharInput(value)
renderDeleteOrFingerprintButtons(binding, size)
}
}
private fun setupDeleteButton(view: View, binding: FragmentLockScreenBinding) {
view.setOnClickListener {
val size = binding.codeView.deleteLast()
renderDeleteOrFingerprintButtons(binding, size)
}
}
private fun setupFingerprintButton(view: View) {
view.setOnClickListener {
showBiometricPrompt()
}
}
private fun setupLeftButton(view: TextView, lockScreenMode: LockScreenMode, configuration: LockScreenConfiguration) = with(view) {
isVisible = lockScreenMode == LockScreenMode.VERIFY && configuration.leftButtonVisible
configuration.leftButtonTitle?.let { text = it }
setOnClickListener(onLeftButtonClickedListener)
}
private fun renderDeleteOrFingerprintButtons(binding: FragmentLockScreenBinding, digits: Int) = withState(viewModel) { state ->
val showFingerprintButton = state.canUseBiometricAuth && !state.isBiometricKeyInvalidated && digits == 0
binding.buttonFingerPrint.isVisible = showFingerprintButton
binding.buttonDelete.isVisible = !showFingerprintButton && digits > 0
}
private fun showBiometricPrompt() {
viewModel.handle(LockScreenAction.ShowBiometricPrompt(requireActivity()))
}
}

View File

@ -0,0 +1,69 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui
/**
* Listener class to be notified of any event that could happen in the lock screen UI.
*/
interface LockScreenListener {
/**
* In PIN creation mode, called when the first PIN code has been entered.
*/
fun onFirstCodeEntered() = Unit
/**
* In PIN creation mode, called when the confirmation PIN code doesn't match the first one.
*/
fun onNewCodeValidationFailed() = Unit
/**
* In PIN creation mode, called when the PIN code was successfully set up.
*/
fun onPinCodeCreated() = Unit
/**
* In verification mode, called when the authentication succeeded.
* @param authMethod Authentication method used ([AuthMethod.PIN_CODE] or [AuthMethod.BIOMETRICS]).
*/
fun onAuthenticationSuccess(authMethod: AuthMethod) = Unit
/**
* In verification mode, called when the authentication failed. At this point the user can usually still retry the authentication.
* @param authMethod Authentication method used ([AuthMethod.PIN_CODE] or [AuthMethod.BIOMETRICS]).
*/
fun onAuthenticationFailure(authMethod: AuthMethod) = Unit
/**
* In verification mode, called when the authentication had a fatal error and can't continue. This is not an authentication failure.
* @param authMethod Authentication method used ([AuthMethod.PIN_CODE] or [AuthMethod.BIOMETRICS]).
* @param throwable The error thrown when the authentication flow was interrupted.
*/
fun onAuthenticationError(authMethod: AuthMethod, throwable: Throwable) = Unit
/**
* In verification mode, called when the system authentication key (used for biometrics) has been invalidated and cannot be used anymore.
*/
fun onBiometricKeyInvalidated() = Unit
}
/**
* Enum containing the available authentication methods.
*/
enum class AuthMethod {
PIN_CODE,
BIOMETRICS
}

View File

@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui
import im.vector.app.core.platform.VectorViewEvents
sealed class LockScreenViewEvent : VectorViewEvents {
data class ClearPinCode(val confirmationFailed: Boolean) : LockScreenViewEvent()
object CodeCreationComplete : LockScreenViewEvent()
data class AuthSuccessful(val method: AuthMethod) : LockScreenViewEvent()
data class AuthFailure(val method: AuthMethod) : LockScreenViewEvent()
data class AuthError(val method: AuthMethod, val throwable: Throwable) : LockScreenViewEvent()
}

View File

@ -0,0 +1,186 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui
import android.annotation.SuppressLint
import android.os.Build
import android.security.keystore.KeyPermanentlyInvalidatedException
import androidx.fragment.app.FragmentActivity
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.airbnb.mvrx.withState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.pin.lockscreen.biometrics.BiometricAuthError
import im.vector.app.features.pin.lockscreen.biometrics.BiometricHelper
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguratorProvider
import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode
import im.vector.app.features.pin.lockscreen.pincode.PinCodeHelper
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider
class LockScreenViewModel @AssistedInject constructor(
@Assisted val initialState: LockScreenViewState,
private val pinCodeHelper: PinCodeHelper,
private val biometricHelper: BiometricHelper,
private val configuratorProvider: LockScreenConfiguratorProvider,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : VectorViewModel<LockScreenViewState, LockScreenAction, LockScreenViewEvent>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LockScreenViewModel, LockScreenViewState> {
override fun create(initialState: LockScreenViewState): LockScreenViewModel
}
companion object : MavericksViewModelFactory<LockScreenViewModel, LockScreenViewState> by hiltMavericksViewModelFactory() {
override fun initialState(viewModelContext: ViewModelContext): LockScreenViewState {
return LockScreenViewState(
lockScreenConfiguration = DUMMY_CONFIGURATION,
canUseBiometricAuth = false,
showBiometricPromptAutomatically = false,
pinCodeState = PinCodeState.Idle,
isBiometricKeyInvalidated = false,
)
}
private val DUMMY_CONFIGURATION = LockScreenConfiguration(
mode = LockScreenMode.VERIFY,
pinCodeLength = 4,
isStrongBiometricsEnabled = false,
isDeviceCredentialUnlockEnabled = false,
isWeakBiometricsEnabled = false,
needsNewCodeValidation = false,
)
}
private var firstEnteredCode: String? = null
// BiometricPrompt will automatically disable system auth after too many failed auth attempts
private var isSystemAuthTemporarilyDisabledByBiometricPrompt = false
init {
// We need this to run synchronously before we start reading the configurations
runBlocking { pinCodeHelper.migratePinCodeIfNeeded() }
configuratorProvider.configurationFlow
.onEach { updateConfiguration(it) }
.launchIn(viewModelScope)
}
override fun handle(action: LockScreenAction) {
when (action) {
is LockScreenAction.PinCodeEntered -> onPinCodeEntered(action.value)
is LockScreenAction.ShowBiometricPrompt -> showBiometricPrompt(action.callingActivity)
}
}
private fun onPinCodeEntered(code: String) = flow {
val state = awaitState()
when (state.lockScreenConfiguration.mode) {
LockScreenMode.CREATE -> {
if (firstEnteredCode == null && state.lockScreenConfiguration.needsNewCodeValidation) {
firstEnteredCode = code
_viewEvents.post(LockScreenViewEvent.ClearPinCode(false))
emit(PinCodeState.FirstCodeEntered)
} else {
if (!state.lockScreenConfiguration.needsNewCodeValidation || code == firstEnteredCode) {
pinCodeHelper.createPinCode(code)
_viewEvents.post(LockScreenViewEvent.CodeCreationComplete)
emit(null)
} else {
firstEnteredCode = null
_viewEvents.post(LockScreenViewEvent.ClearPinCode(true))
emit(PinCodeState.Idle)
}
}
}
LockScreenMode.VERIFY -> {
if (pinCodeHelper.verifyPinCode(code)) {
_viewEvents.post(LockScreenViewEvent.AuthSuccessful(AuthMethod.PIN_CODE))
emit(null)
} else {
_viewEvents.post(LockScreenViewEvent.AuthFailure(AuthMethod.PIN_CODE))
emit(null)
}
}
}
}.catch { error ->
_viewEvents.post(LockScreenViewEvent.AuthError(AuthMethod.PIN_CODE, error))
}.onEach { newPinState ->
newPinState?.let { setState { copy(pinCodeState = it) } }
}.launchIn(viewModelScope)
@SuppressLint("NewApi")
private fun showBiometricPrompt(activity: FragmentActivity) = flow {
emitAll(biometricHelper.authenticate(activity))
}.catch { error ->
if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M && error is KeyPermanentlyInvalidatedException) {
removeBiometricAuthentication()
} else if (error is BiometricAuthError && error.isAuthDisabledError) {
isSystemAuthTemporarilyDisabledByBiometricPrompt = true
updateStateWithBiometricInfo()
}
_viewEvents.post(LockScreenViewEvent.AuthError(AuthMethod.BIOMETRICS, error))
}.onEach { success ->
_viewEvents.post(
if (success) LockScreenViewEvent.AuthSuccessful(AuthMethod.BIOMETRICS)
else LockScreenViewEvent.AuthFailure(AuthMethod.BIOMETRICS)
)
}.launchIn(viewModelScope)
fun reset() {
configuratorProvider.reset()
}
private fun removeBiometricAuthentication() {
biometricHelper.disableAuthentication()
updateStateWithBiometricInfo()
}
private fun updateStateWithBiometricInfo() {
val configuration = withState(this) { it.lockScreenConfiguration }
val canUseBiometricAuth = configuration.mode == LockScreenMode.VERIFY &&
!isSystemAuthTemporarilyDisabledByBiometricPrompt &&
biometricHelper.isSystemAuthEnabledAndValid
val isBiometricKeyInvalidated = biometricHelper.hasSystemKey && !biometricHelper.isSystemKeyValid
val showBiometricPromptAutomatically = canUseBiometricAuth &&
configuration.autoStartBiometric
setState {
copy(
canUseBiometricAuth = canUseBiometricAuth,
showBiometricPromptAutomatically = showBiometricPromptAutomatically,
isBiometricKeyInvalidated = isBiometricKeyInvalidated
)
}
}
private fun updateConfiguration(configuration: LockScreenConfiguration) {
setState { copy(lockScreenConfiguration = configuration) }
updateStateWithBiometricInfo()
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration
data class LockScreenViewState(
val lockScreenConfiguration: LockScreenConfiguration,
val canUseBiometricAuth: Boolean,
val showBiometricPromptAutomatically: Boolean,
val pinCodeState: PinCodeState,
val isBiometricKeyInvalidated: Boolean,
) : MavericksState
sealed class PinCodeState {
object Idle : PinCodeState()
object FirstCodeEntered : PinCodeState()
}

View File

@ -0,0 +1,159 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.ui.fallbackprompt
import android.content.DialogInterface
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import android.view.ViewGroup
import androidx.biometric.BiometricPrompt
import androidx.core.content.res.ResourcesCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Mavericks
import com.airbnb.mvrx.args
import im.vector.app.R
import im.vector.app.databinding.FragmentBiometricDialogContainerBinding
import im.vector.app.databinding.ViewBiometricDialogContentBinding
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
/**
* A fragment to be displayed on devices that have issues with [BiometricPrompt].
*/
class FallbackBiometricDialogFragment : DialogFragment(R.layout.fragment_biometric_dialog_container) {
var onDismiss: (() -> Unit)? = null
var authenticationFlow: Flow<Boolean>? = null
private var binding: ViewBiometricDialogContentBinding? = null
private val parsedArgs by args<Args>()
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
setStyle(STYLE_NORMAL, android.R.style.Theme_Material_Light_Dialog)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
FragmentBiometricDialogContainerBinding.bind(view).apply {
parsedArgs.cancelActionText?.let { cancelButton.text = it }
}
val content = view.findViewById<ViewGroup>(R.id.dialogContent).getChildAt(0)
binding = ViewBiometricDialogContentBinding.bind(content).apply {
parsedArgs.description?.let { fingerprintDescription.text = it }
}
requireDialog().setTitle(parsedArgs.title ?: getString(R.string.lockscreen_sign_in))
}
override fun onResume() {
super.onResume()
val authFlow = authenticationFlow ?: return
viewLifecycleOwner.lifecycleScope.launch {
authFlow.catch {
dismiss()
}.collect { success ->
if (success) {
renderSuccess()
} else {
renderFailure()
}
}
}
}
private fun renderSuccess() {
val contentBinding = binding ?: return
contentBinding.fingerprintIcon.setImageResource(R.drawable.ic_fingerprint_success_lockscreen)
contentBinding.fingerprintStatus.apply {
setTextColor(ResourcesCompat.getColor(resources, R.color.lockscreen_success_color, null))
setText(R.string.lockscreen_fingerprint_success)
}
viewLifecycleOwner.lifecycleScope.launch {
delay(200L)
dismiss()
}
}
private fun renderFailure() {
val contentBinding = binding ?: return
contentBinding.fingerprintIcon.setImageResource(R.drawable.ic_fingerprint_error_lockscreen)
contentBinding.fingerprintStatus.apply {
setTextColor(ResourcesCompat.getColor(resources, R.color.lockscreen_warning_color, null))
setText(R.string.lockscreen_fingerprint_not_recognized)
}
viewLifecycleOwner.lifecycleScope.launch {
delay(1500L)
resetState()
}
}
private fun resetState() {
val contentBinding = binding ?: return
contentBinding.fingerprintIcon.setImageResource(R.drawable.lockscreen_fingerprint_40)
contentBinding.fingerprintStatus.apply {
setTextColor(ResourcesCompat.getColor(resources, R.color.lockscreen_hint_color, null))
setText(R.string.lockscreen_fingerprint_hint)
}
}
override fun onDestroyView() {
super.onDestroyView()
binding = null
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
onDismiss?.invoke()
}
@Parcelize
data class Args(
val title: String? = null,
val description: String? = null,
val cancelActionText: String? = null,
) : Parcelable
companion object {
fun instantiate(
title: String? = null,
description: String? = null,
cancelActionText: String? = null,
): FallbackBiometricDialogFragment {
return FallbackBiometricDialogFragment().also {
val args = Args(title, description, cancelActionText)
it.arguments = bundleOf(Mavericks.KEY_ARG to args)
}
}
}
}

View File

@ -0,0 +1,65 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.utils
import android.os.Build
import androidx.biometric.BiometricPrompt
/**
* Helper to detect devices with [BiometricPrompt] issues.
* The device lists are taken from [this repository](https://github.com/sergeykomlach/AdvancedBiometricPromptCompat/), in DevicesWithKnownBugs.kt.
*/
object DevicePromptCheck {
private val onePlusModelsWithWorkingBiometricUI = setOf(
"A0001", // OnePlus One
"ONE A2001", "ONE A2003", "ONE A2005", // OnePlus 2
"ONE E1001", "ONE E1003", "ONE E1005", // OnePlus X
"ONEPLUS A3000", "ONEPLUS SM-A3000", "ONEPLUS A3003", // OnePlus 3
"ONEPLUS A3010", // OnePlus 3T
"ONEPLUS A5000", // OnePlus 5
"ONEPLUS A5010", // OnePlus 5T
"ONEPLUS A6000", "ONEPLUS A6003", // OnePlus 6
)
private val lgModelsWithoutBiometricUI = setOf(
"G810", // G8 ThinQ "G820", G8S ThinQ
"G850", // G8X ThinQ
"G900", // Velvet/Velvet 5G
"G910", // Velvet 4G Dual Sim
)
/**
* New OnePlus devices have a bug that prevents the system biometric UI from appearing, only the under display fingerprint is shown.
* See [this OP forum thread](https://forums.oneplus.com/threads/oneplus-7-pro-fingerprint-biometricprompt-does-not-show.1035821/).
*/
private val isOnePlusDeviceWithNoBiometricUI: Boolean =
Build.BRAND.equals("OnePlus", ignoreCase = true) &&
!onePlusModelsWithWorkingBiometricUI.contains(Build.MODEL) &&
Build.VERSION.SDK_INT < Build.VERSION_CODES.R
/**
* Some LG models don't seem to have a system biometric prompt at all.
*/
private val isLGDeviceWithNoBiometricUI: Boolean =
Build.BRAND.equals("LG", ignoreCase = true) && lgModelsWithoutBiometricUI.contains(Build.MODEL)
/**
* Check if this device is included in the list of devices with known Biometric Prompt issues.
*/
val isDeviceWithNoBiometricUI: Boolean = isOnePlusDeviceWithNoBiometricUI || isLGDeviceWithNoBiometricUI
}

View File

@ -0,0 +1,22 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.utils
/**
* Returns true if the [Int] value contains the provided bit [flag].
*/
internal fun Int.hasFlag(flag: Int) = this and flag == flag

View File

@ -0,0 +1,170 @@
/*
* Copyright (c) 2022 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 im.vector.app.features.pin.lockscreen.views
import android.annotation.SuppressLint
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.widget.CheckBox
import android.widget.LinearLayout
import androidx.core.view.setMargins
import im.vector.app.R
/**
* Custom view representing the entered digits of a PIN code screen.
*/
class LockScreenCodeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {
private val code: MutableList<Char> = mutableListOf()
/**
* Number of digits entered.
*/
val enteredDigits: Int get() = code.size
/**
* Callback called when the PIN code has been completely entered.
*/
var onCodeCompleted: CodeCompletedListener? = null
var codeLength: Int = 0
set(value) {
if (value == field) return
field = value
setupCodeViews()
code.clear()
}
init {
isSaveEnabled = true
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT).also { it.gravity = Gravity.CENTER_HORIZONTAL }
orientation = HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
}
@SuppressLint("InflateParams")
private fun setupCodeViews() {
removeAllViews()
val inflater = LayoutInflater.from(context)
repeat(codeLength) { index ->
val checkBox = inflater.inflate(R.layout.view_code_checkbox, null) as CheckBox
val params = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
val margin = resources.getDimensionPixelSize(R.dimen.lockscreen_code_margin)
params.setMargins(margin)
checkBox.layoutParams = params
checkBox.isChecked = code.size > index
addView(checkBox)
}
}
private fun getCodeView(index: Int): CheckBox? = getChildAt(index) as? CheckBox
/**
* Adds a new [character] to the PIN code. Once it reaches the [codeLength] needed it will invoke the [onCodeCompleted] callback.
*/
fun onCharInput(character: Char): Int {
if (code.size == codeLength) return code.size
getCodeView(code.size)?.toggle()
code.add(character)
if (code.size == codeLength) {
onCodeCompleted?.onCodeCompleted(String(code.toCharArray()))
}
return code.size
}
/**
* Deletes the last digit in the PIN code if possible.
*/
fun deleteLast(): Int {
if (code.size == 0) return code.size
code.removeLast()
getCodeView(code.size)?.toggle()
return code.size
}
/**
* Removes all digits in the PIN code.
*/
fun clearCode() {
code.clear()
repeat(codeLength) { getCodeView(it)?.isChecked = false }
}
override fun onSaveInstanceState(): Parcelable {
return SavedState(super.onSaveInstanceState()!!).also {
it.code = code
it.codeLength = codeLength
}
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is SavedState) {
codeLength = state.codeLength
code.addAll(state.code)
}
super.onRestoreInstanceState(state)
setupCodeViews()
}
/**
* Used to listen to when [LockScreenCodeView] receives a whole PIN code.
*/
fun interface CodeCompletedListener {
fun onCodeCompleted(code: String)
}
internal class SavedState : BaseSavedState {
var code: MutableList<Char> = mutableListOf()
var codeLength: Int = 0
constructor(source: Parcel) : super(source) {
source.readList(code, null)
codeLength = source.readInt()
}
constructor(superState: Parcelable) : super(superState)
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeList(code)
out.writeInt(codeLength)
}
companion object {
@JvmField
val CREATOR = object : Parcelable.Creator<SavedState> {
override fun createFromParcel(source: Parcel): SavedState {
return SavedState(source)
}
override fun newArray(size: Int): Array<SavedState?> {
return arrayOfNulls(size)
}
}
}
}
}

View File

@ -180,7 +180,7 @@ class VectorPreferences @Inject constructor(
const val SETTINGS_SECURITY_USE_FLAG_SECURE = "SETTINGS_SECURITY_USE_FLAG_SECURE"
const val SETTINGS_SECURITY_USE_PIN_CODE_FLAG = "SETTINGS_SECURITY_USE_PIN_CODE_FLAG"
const val SETTINGS_SECURITY_CHANGE_PIN_CODE_FLAG = "SETTINGS_SECURITY_CHANGE_PIN_CODE_FLAG"
private const val SETTINGS_SECURITY_USE_BIOMETRICS_FLAG = "SETTINGS_SECURITY_USE_BIOMETRICS_FLAG"
const val SETTINGS_SECURITY_USE_BIOMETRICS_FLAG = "SETTINGS_SECURITY_USE_BIOMETRICS_FLAG"
private const val SETTINGS_SECURITY_USE_GRACE_PERIOD_FLAG = "SETTINGS_SECURITY_USE_GRACE_PERIOD_FLAG"
const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG"
@ -945,6 +945,10 @@ class VectorPreferences @Inject constructor(
return defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_PIN_CODE_FLAG, false)
}
fun setUseBiometricToUnlock(value: Boolean) {
defaultPrefs.edit { putBoolean(SETTINGS_SECURITY_USE_BIOMETRICS_FLAG, value) }
}
fun useBiometricsToUnlock(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_BIOMETRICS_FLAG, true)
}

View File

@ -22,17 +22,23 @@ import androidx.preference.SwitchPreference
import im.vector.app.R
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.preference.VectorPreference
import im.vector.app.core.utils.toast
import im.vector.app.features.navigation.Navigator
import im.vector.app.features.notifications.NotificationDrawerManager
import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.PinMode
import im.vector.app.features.pin.lockscreen.biometrics.BiometricHelper
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import javax.inject.Inject
class VectorSettingsPinFragment @Inject constructor(
private val pinCodeStore: PinCodeStore,
private val navigator: Navigator,
private val notificationDrawerManager: NotificationDrawerManager
private val notificationDrawerManager: NotificationDrawerManager,
private val biometricHelper: BiometricHelper,
) : VectorSettingsBaseFragment() {
override var titleRes = R.string.settings_security_application_protection_screen_title
@ -50,14 +56,67 @@ class VectorSettingsPinFragment @Inject constructor(
findPreference<SwitchPreference>(VectorPreferences.SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG)!!
}
private val useBiometricPref by lazy {
findPreference<SwitchPreference>(VectorPreferences.SETTINGS_SECURITY_USE_BIOMETRICS_FLAG)!!
}
private fun shouldCheckBiometricPref(isPinCodeChecked: Boolean): Boolean {
return isPinCodeChecked && // Biometric auth depends on PIN auth
biometricHelper.isSystemAuthEnabledAndValid &&
biometricHelper.isSystemKeyValid
}
override fun onResume() {
super.onResume()
useBiometricPref.isEnabled = usePinCodePref.isChecked
useBiometricPref.isChecked = shouldCheckBiometricPref(usePinCodePref.isChecked)
}
override fun bindPref() {
refreshPinCodeStatus()
usePinCodePref.setOnPreferenceChangeListener { _, value ->
val isChecked = (value as? Boolean).orFalse()
useBiometricPref.isEnabled = isChecked
useBiometricPref.isChecked = shouldCheckBiometricPref(isChecked)
if (!isChecked) {
disableBiometricAuthentication()
}
true
}
useCompleteNotificationPref.setOnPreferenceChangeListener { _, _ ->
// Refresh the drawer for an immediate effect of this change
notificationDrawerManager.notificationStyleChanged()
true
}
useBiometricPref.setOnPreferenceChangeListener { _, newValue ->
if (newValue as? Boolean == true) {
viewLifecycleOwner.lifecycleScope.launch {
runCatching {
// If previous system key existed, delete it
if (biometricHelper.hasSystemKey) {
biometricHelper.disableAuthentication()
}
biometricHelper.enableAuthentication(requireActivity()).collect()
}.onFailure {
showEnableBiometricErrorMessage()
}
useBiometricPref.isChecked = shouldCheckBiometricPref(usePinCodePref.isChecked)
}
false
} else {
disableBiometricAuthentication()
true
}
}
}
private fun disableBiometricAuthentication() {
runCatching { biometricHelper.disableAuthentication() }
.onFailure { Timber.e(it) }
}
private fun refreshPinCodeStatus() {
@ -67,7 +126,7 @@ class VectorSettingsPinFragment @Inject constructor(
usePinCodePref.onPreferenceClickListener = Preference.OnPreferenceClickListener {
if (hasPinCode) {
lifecycleScope.launch {
pinCodeStore.deleteEncodedPin()
pinCodeStore.deletePinCode()
refreshPinCodeStatus()
}
} else {
@ -93,6 +152,10 @@ class VectorSettingsPinFragment @Inject constructor(
}
}
private fun showEnableBiometricErrorMessage() {
context?.toast(R.string.settings_security_pin_code_use_biometrics_error)
}
private val pinActivityResultLauncher = registerStartForActivityResult {
refreshPinCodeStatus()
}

View File

@ -25,6 +25,7 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.auth.ReAuthActivity
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.UserPasswordAuth
@ -45,7 +46,8 @@ data class DeactivateAccountViewState(
class DeactivateAccountViewModel @AssistedInject constructor(
@Assisted private val initialState: DeactivateAccountViewState,
private val session: Session
private val session: Session,
private val matrix: Matrix,
) :
VectorViewModel<DeactivateAccountViewState, DeactivateAccountAction, DeactivateAccountViewEvents>(initialState) {
@ -71,7 +73,7 @@ class DeactivateAccountViewModel @AssistedInject constructor(
}
is DeactivateAccountAction.PasswordAuthDone -> {
_viewEvents.post(DeactivateAccountViewEvents.Loading())
val decryptedPass = session.secureStorageService()
val decryptedPass = matrix.secureStorageService()
.loadSecureSecret<String>(action.password.fromBase64().inputStream(), ReAuthActivity.DEFAULT_RESULT_KEYSTORE_ALIAS)
uiaContinuation?.resume(
UserPasswordAuth(

View File

@ -29,6 +29,7 @@ import im.vector.app.features.login.ReAuthHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.UserPasswordAuth
@ -50,7 +51,8 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
@Assisted private val initialState: CrossSigningSettingsViewState,
private val session: Session,
private val reAuthHelper: ReAuthHelper,
private val stringProvider: StringProvider
private val stringProvider: StringProvider,
private val matrix: Matrix,
) : VectorViewModel<CrossSigningSettingsViewState, CrossSigningSettingsAction, CrossSigningSettingsViewEvents>(initialState) {
init {
@ -132,7 +134,7 @@ class CrossSigningSettingsViewModel @AssistedInject constructor(
}
}
is CrossSigningSettingsAction.PasswordAuthDone -> {
val decryptedPass = session.secureStorageService()
val decryptedPass = matrix.secureStorageService()
.loadSecureSecret<String>(action.password.fromBase64().inputStream(), ReAuthActivity.DEFAULT_RESULT_KEYSTORE_ALIAS)
uiaContinuation?.resume(
UserPasswordAuth(

View File

@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.NoOpMatrixCallback
import org.matrix.android.sdk.api.auth.UIABaseAuth
@ -90,7 +91,8 @@ class DevicesViewModel @AssistedInject constructor(
@Assisted initialState: DevicesViewState,
private val session: Session,
private val reAuthHelper: ReAuthHelper,
private val stringProvider: StringProvider
private val stringProvider: StringProvider,
private val matrix: Matrix,
) : VectorViewModel<DevicesViewState, DevicesAction, DevicesViewEvents>(initialState), VerificationService.Listener {
var uiaContinuation: Continuation<UIABaseAuth>? = null
@ -219,7 +221,7 @@ class DevicesViewModel @AssistedInject constructor(
Unit
}
is DevicesAction.PasswordAuthDone -> {
val decryptedPass = session.secureStorageService()
val decryptedPass = matrix.secureStorageService()
.loadSecureSecret<String>(action.password.fromBase64().inputStream(), ReAuthActivity.DEFAULT_RESULT_KEYSTORE_ALIAS)
uiaContinuation?.resume(
UserPasswordAuth(

View File

@ -30,6 +30,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ReadOnceTrue
import im.vector.app.features.auth.ReAuthActivity
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.UserPasswordAuth
@ -47,7 +48,8 @@ import kotlin.coroutines.resumeWithException
class ThreePidsSettingsViewModel @AssistedInject constructor(
@Assisted initialState: ThreePidsSettingsViewState,
private val session: Session,
private val stringProvider: StringProvider
private val stringProvider: StringProvider,
private val matrix: Matrix,
) : VectorViewModel<ThreePidsSettingsViewState, ThreePidsSettingsAction, ThreePidsSettingsViewEvents>(initialState) {
// UIA session
@ -133,7 +135,7 @@ class ThreePidsSettingsViewModel @AssistedInject constructor(
}
}
is ThreePidsSettingsAction.PasswordAuthDone -> {
val decryptedPass = session.secureStorageService()
val decryptedPass = matrix.secureStorageService()
.loadSecureSecret<String>(action.password.fromBase64().inputStream(), ReAuthActivity.DEFAULT_RESULT_KEYSTORE_ALIAS)
uiaContinuation?.resume(
UserPasswordAuth(

View File

@ -54,7 +54,7 @@ class CreateSpaceAdd3pidInvitesFragment @Inject constructor(
invalidateState(it)
}
views.nextButton.setText(R.string.next_pf)
views.nextButton.setText(R.string.action_next)
views.nextButton.debouncedClicks {
view.hideKeyboard()
sharedViewModel.handle(CreateSpaceAction.NextFromAdd3pid)
@ -67,7 +67,7 @@ class CreateSpaceAdd3pidInvitesFragment @Inject constructor(
views.nextButton.text = if (noEmails) {
getString(R.string.skip_for_now)
} else {
getString(R.string.next_pf)
getString(R.string.action_next)
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromXDelta="0"
android:toXDelta="10"
android:duration="500"
android:interpolator="@anim/lockscreen_shake_interpolator" />

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<cycleInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
android:cycles="7" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2015 The Android Open Source Project
~
~ 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
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40.0dp"
android:height="40.0dp"
android:viewportWidth="40.0"
android:viewportHeight="40.0">
<path
android:pathData="M20.0,0.0C8.96,0.0 0.0,8.95 0.0,20.0s8.96,20.0 20.0,20.0c11.04,0.0 20.0,-8.95 20.0,-20.0S31.04,0.0 20.0,0.0z"
android:fillColor="#F4511E"/>
<path
android:pathData="M21.33,29.33l-2.67,0.0l0.0,-2.67l2.67,0.0L21.33,29.33zM21.33,22.67l-2.67,0.0l0.0,-12.0l2.67,0.0L21.33,22.67z"
android:fillColor="#FFFFFF"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More