Re-implement lock screen feature using our own implementation.
This commit is contained in:
parent
32c6281dd2
commit
b5aedd4626
|
@ -16,4 +16,4 @@
|
|||
/fastlane/private
|
||||
/fastlane/report.xml
|
||||
|
||||
/library/build
|
||||
/**/build
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Improve lock screen implementation.
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()`.
|
||||
*/
|
||||
|
|
|
@ -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 {
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*/
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()))
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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() }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -195,7 +195,7 @@ class MainActivity : VectorBaseActivity<ActivityMainBinding>(), UnlockedActivity
|
|||
vectorPreferences.clearPreferences()
|
||||
uiStateRepository.reset()
|
||||
pinLocker.unlock()
|
||||
pinCodeStore.deleteEncodedPin()
|
||||
pinCodeStore.deletePinCode()
|
||||
vectorAnalytics.onSignOut()
|
||||
vectorSessionStore.clear()
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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" />
|
|
@ -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 |
|
@ -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
Loading…
Reference in New Issue