Settings activity app lock (#2926)

* Extract app lock timout functionality from WebViewActivity

* Add app lock authentication to SettingsActivity.
Handle enabling app lock setting authentication in SettingsActivity to avoid callback clash.

* Add blurview to SettingsActivity when app is locked

* Increase blur radius: 5px seems small enough to have text still somewhat legible

* Cleanup

* Cache appLock state for use in touch listeners

* Review rework

* Add onWindowFocusChanged to settingsActivity to check for authentication

* Cleanup SessionExpireMillis functions

* Fix logging formats

* Cleanup debug logging in onResume / onWindowFocusChanged handlers

* Linter fix

* Test SettingsActivity blurview without explicit background

* Improve isAppLocked() debug logging

* Increase applock grace period

* Fix setLockAuthenticationResult return value

* remove authentication from onResume. Perform only onWindowFocusChanged.

* Prevent double authentication prompt when enabling app lock

* Fix missing NotificationManagerCompat import
This commit is contained in:
RoboMagus 2022-10-21 19:41:24 +02:00 committed by GitHub
parent 07845fb797
commit 1f226a71fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 214 additions and 92 deletions

View file

@ -3,26 +3,46 @@ package io.homeassistant.companion.android.settings
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView
import androidx.biometric.BiometricManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.components.ActivityComponent
import eightbitlab.com.blurview.BlurView
import eightbitlab.com.blurview.RenderScriptBlur
import io.homeassistant.companion.android.BaseActivity
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.authenticator.Authenticator
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.settings.notification.NotificationHistoryFragment
import io.homeassistant.companion.android.settings.qs.ManageTilesFragment
import io.homeassistant.companion.android.settings.sensor.SensorDetailFragment
import io.homeassistant.companion.android.settings.websocket.WebsocketSettingFragment
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class SettingsActivity : BaseActivity() {
@Inject
lateinit var integrationUseCase: IntegrationRepository
private lateinit var authenticator: Authenticator
private lateinit var blurView: BlurView
private var authenticating = false
private var externalAuthCallback: ((Int) -> Boolean)? = null
companion object {
private const val TAG = "SettingsActivity"
fun newInstance(context: Context): Intent {
return Intent(context, SettingsActivity::class.java)
}
@ -49,6 +69,16 @@ class SettingsActivity : BaseActivity() {
setSupportActionBar(findViewById(R.id.toolbar))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
blurView = findViewById(R.id.blurView)
blurView.setupWith(window.decorView.rootView as ViewGroup)
.setBlurAlgorithm(RenderScriptBlur(this))
.setBlurAutoUpdate(true)
.setBlurRadius(8f)
.setHasFixedTransformationMatrix(false)
.setBlurEnabled(false)
authenticator = Authenticator(this, this, ::settingsActivityAuthenticationResult)
if (savedInstanceState == null) {
val settingsNavigation = intent.getStringExtra("fragment")
supportFragmentManager
@ -74,6 +104,76 @@ class SettingsActivity : BaseActivity() {
}
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
runBlocking {
integrationUseCase.setAppActive(false)
}
}
override fun onPause() {
super.onPause()
runBlocking {
integrationUseCase.setAppActive(false)
}
}
override fun onResume() {
super.onResume()
val appLocked = runBlocking {
integrationUseCase.isAppLocked()
}
blurView.setBlurEnabled(appLocked)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
val appLocked = runBlocking {
integrationUseCase.isAppLocked()
}
if (appLocked) {
authenticating = true
authenticator.authenticate(getString(commonR.string.biometric_title))
blurView.setBlurEnabled(true)
} else {
blurView.setBlurEnabled(false)
}
}
}
private fun settingsActivityAuthenticationResult(result: Int) {
val isExtAuth = (externalAuthCallback != null)
Log.d(TAG, "settingsActivityAuthenticationResult(): authenticating: $authenticating, externalAuth: $isExtAuth")
externalAuthCallback?.let {
if (it(result) == true) {
externalAuthCallback = null
}
}
if (authenticating) {
authenticating = false
when (result) {
Authenticator.SUCCESS -> {
Log.d(TAG, "Authentication successful, unlocking app")
blurView.setBlurEnabled(false)
runBlocking {
integrationUseCase.setAppActive(true)
}
}
Authenticator.CANCELED -> {
Log.d(TAG, "Authentication canceled by user, closing activity")
finishAffinity()
}
else -> Log.d(TAG, "Authentication failed, retry attempts allowed")
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
@ -88,6 +188,17 @@ class SettingsActivity : BaseActivity() {
}
}
fun requestAuthentication(title: String, callback: (Int) -> Boolean): Boolean {
return if (BiometricManager.from(this).canAuthenticate() != BiometricManager.BIOMETRIC_SUCCESS) {
false
} else {
externalAuthCallback = callback
authenticator.authenticate(title)
true
}
}
@EntryPoint
@InstallIn(ActivityComponent::class)
interface SettingsFragmentFactoryEntryPoint {

View file

@ -17,7 +17,6 @@ import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.biometric.BiometricManager
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
@ -69,9 +68,6 @@ class SettingsFragment constructor(
private const val BACKGROUND_LOCATION_REQUEST_CODE = 1
}
private lateinit var authenticator: Authenticator
private var setLock = false
private val requestBackgroundAccessResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
updateBackgroundAccessPref()
}
@ -83,8 +79,6 @@ class SettingsFragment constructor(
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
presenter.init(this)
authenticator = Authenticator(requireContext(), requireActivity(), ::authenticationResult)
preferenceManager.preferenceDataStore = presenter.getPreferenceDataStore()
setPreferencesFromResource(R.xml.preferences, rootKey)
@ -102,18 +96,17 @@ class SettingsFragment constructor(
}
findPreference<SwitchPreference>("app_lock")?.setOnPreferenceChangeListener { _, newValue ->
var isValid: Boolean
val isValid: Boolean
if (newValue == false) {
isValid = true
findPreference<SwitchPreference>("app_lock_home_bypass")?.isVisible = false
findPreference<EditTextPreference>("session_timeout")?.isVisible = false
} else {
isValid = true
if (BiometricManager.from(requireActivity()).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) {
setLock = true
authenticator.authenticate(getString(commonR.string.biometric_set_title))
} else {
isValid = false
val settingsActivity = requireActivity() as SettingsActivity
val canAuth = settingsActivity.requestAuthentication(getString(commonR.string.biometric_set_title), ::setLockAuthenticationResult)
isValid = canAuth
if (!canAuth) {
AlertDialog.Builder(requireActivity())
.setTitle(commonR.string.set_lock_title)
.setMessage(commonR.string.set_lock_message)
@ -493,13 +486,17 @@ class SettingsFragment constructor(
.commit()
}
private fun authenticationResult(result: Int) {
private fun setLockAuthenticationResult(result: Int): Boolean {
val success = result == Authenticator.SUCCESS
val switchLock = findPreference<SwitchPreference>("app_lock")
switchLock?.isChecked = success
// Prevent requesting authentication after just enabling the app lock
presenter.setAppActive(success)
findPreference<SwitchPreference>("app_lock_home_bypass")?.isVisible = success
findPreference<EditTextPreference>("session_timeout")?.isVisible = success
return (result == Authenticator.SUCCESS || result == Authenticator.CANCELED)
}
private fun removeSystemFromThemesIfNeeded() {

View file

@ -11,12 +11,9 @@ interface SettingsPresenter {
fun onFinish()
fun updateExternalUrlStatus()
fun updateInternalUrlStatus()
fun isLockEnabled(): Boolean
fun sessionTimeOut(): Int
fun setAppActive(active: Boolean)
suspend fun getNotificationRateLimits(): RateLimitResponse?
fun setSessionExpireMillis(value: Long)
fun getSessionExpireMillis(): Long
fun isSsidUsed(): Boolean
fun clearSsids()
fun showChangeLog(context: Context)

View file

@ -173,27 +173,9 @@ class SettingsPresenterImpl @Inject constructor(
settingsView.updateSsids(ssids)
}
override fun isLockEnabled(): Boolean {
return runBlocking {
authenticationUseCase.isLockEnabled()
}
}
override fun sessionTimeOut(): Int {
return runBlocking {
integrationUseCase.getSessionTimeOut()
}
}
override fun setSessionExpireMillis(value: Long) {
mainScope.launch {
integrationUseCase.setSessionExpireMillis(value)
}
}
override fun getSessionExpireMillis(): Long {
return runBlocking {
integrationUseCase.getSessionExpireMillis()
override fun setAppActive(active: Boolean) {
runBlocking {
integrationUseCase.setAppActive(active)
}
}

View file

@ -194,7 +194,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
private var videoHeight = 0
private var firstAuthTime: Long = 0
private var resourceURL: String = ""
private var unlocked = false
private var appLocked = true
private var exoPlayer: SimpleExoPlayer? = null
private var isExoFullScreen = false
private var exoTop: Int = 0 // These margins are from the DOM and scaled to screen
@ -225,7 +225,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
binding.blurView.setupWith(binding.root)
.setBlurAlgorithm(RenderScriptBlur(this))
.setBlurRadius(5f)
.setBlurRadius(8f)
.setHasFixedTransformationMatrix(false)
exoPlayerView = binding.exoplayerView
@ -238,10 +238,8 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
playerBinding = ExoPlayerViewBinding.bind(exoPlayerView)
if (!presenter.isLockEnabled()) {
binding.blurView.setBlurEnabled(false)
unlocked = true
}
appLocked = presenter.isAppLocked()
binding.blurView.setBlurEnabled(appLocked)
authenticator = Authenticator(this, this, ::authenticationResult)
@ -273,11 +271,11 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
) {
dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_E))
}
return !unlocked
return appLocked
}
override fun onMotionEventHandled(v: View?, event: MotionEvent?): Boolean {
return !unlocked
return appLocked
}
})
@ -698,12 +696,9 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
super.onResume()
if ((currentLang != languagesManager.getCurrentLang()) || currentAutoplay != presenter.isAutoPlayVideoEnabled())
recreate()
if ((!unlocked && !presenter.isLockEnabled()) ||
(!unlocked && presenter.isLockEnabled() && System.currentTimeMillis() < presenter.getSessionExpireMillis())
) {
unlocked = true
binding.blurView.setBlurEnabled(false)
}
appLocked = presenter.isAppLocked()
binding.blurView.setBlurEnabled(appLocked)
enablePinchToZoom()
@ -723,6 +718,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
override fun onPause() {
super.onPause()
SensorWorker.start(this)
presenter.setAppActive(false)
}
private fun checkAndWarnForDisabledLocation() {
@ -939,7 +935,8 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
when (result) {
Authenticator.SUCCESS -> {
Log.d(TAG, "Authentication successful, unlocking app")
unlocked = true
appLocked = false
presenter.setAppActive(true)
binding.blurView.setBlurEnabled(false)
}
Authenticator.CANCELED -> {
@ -953,13 +950,13 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
if (presenter.isLockEnabled() && !unlocked)
if ((System.currentTimeMillis() > presenter.getSessionExpireMillis())) {
binding.blurView.setBlurEnabled(true)
authenticator.authenticate(getString(commonR.string.biometric_title))
} else {
binding.blurView.setBlurEnabled(false)
}
appLocked = presenter.isAppLocked()
if (appLocked) {
binding.blurView.setBlurEnabled(true)
authenticator.authenticate(getString(commonR.string.biometric_title))
} else {
binding.blurView.setBlurEnabled(false)
}
val path = intent.getStringExtra(EXTRA_PATH)
presenter.onViewReady(path)
@ -1003,8 +1000,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
override fun onUserLeaveHint() {
super.onUserLeaveHint()
presenter.setSessionExpireMillis((System.currentTimeMillis() + (presenter.sessionTimeOut() * 1000)))
unlocked = false
presenter.setAppActive(false)
videoHeight = decor.height
val bounds = Rect(0, 0, 1920, 1080)
if (isVideoFullScreen or isExoFullScreen) {

View file

@ -21,14 +21,14 @@ interface WebViewPresenter {
fun isPinchToZoomEnabled(): Boolean
fun isWebViewDebugEnabled(): Boolean
fun isAppLocked(): Boolean
fun setAppActive(active: Boolean)
fun isLockEnabled(): Boolean
fun isAutoPlayVideoEnabled(): Boolean
fun sessionTimeOut(): Int
fun setSessionExpireMillis(value: Long)
fun getSessionExpireMillis(): Long
fun onFinish()
fun isSsidUsed(): Boolean

View file

@ -157,6 +157,18 @@ class WebViewPresenterImpl @Inject constructor(
}
}
override fun isAppLocked(): Boolean {
return runBlocking {
integrationUseCase.isAppLocked()
}
}
override fun setAppActive(active: Boolean) {
return runBlocking {
integrationUseCase.setAppActive(active)
}
}
override fun isLockEnabled(): Boolean {
return runBlocking {
authenticationUseCase.isLockEnabled()
@ -175,18 +187,6 @@ class WebViewPresenterImpl @Inject constructor(
}
}
override fun setSessionExpireMillis(value: Long) {
mainScope.launch {
integrationUseCase.setSessionExpireMillis(value)
}
}
override fun getSessionExpireMillis(): Long {
return runBlocking {
integrationUseCase.getSessionExpireMillis()
}
}
override fun onFinish() {
mainScope.cancel()
}

View file

@ -1,19 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.HomeAssistant.ConfigActionBar" />
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:id="@+id/content"
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
android:theme="@style/ThemeOverlay.HomeAssistant.ConfigActionBar" />
<FrameLayout
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
<eightbitlab.com.blurview.BlurView
android:id="@+id/blurView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
android:layout_height="match_parent">
</eightbitlab.com.blurview.BlurView>
</RelativeLayout>

View file

@ -36,11 +36,13 @@ interface IntegrationRepository {
suspend fun setWebViewDebugEnabled(enabled: Boolean)
suspend fun isWebViewDebugEnabled(): Boolean
suspend fun isAppLocked(): Boolean
suspend fun setAppActive(active: Boolean)
suspend fun sessionTimeOut(value: Int)
suspend fun getSessionTimeOut(): Int
suspend fun setSessionExpireMillis(value: Long)
suspend fun getSessionExpireMillis(): Long
suspend fun setControlsAuthRequired(setting: ControlsAuthRequiredSetting)
suspend fun getControlsAuthRequired(): ControlsAuthRequiredSetting

View file

@ -80,8 +80,12 @@ class IntegrationRepositoryImpl @Inject constructor(
private const val PREF_SEC_WARNING_NEXT = "sec_warning_last"
private const val TAG = "IntegrationRepository"
private const val RATE_LIMIT_URL = BuildConfig.RATE_LIMIT_URL
private const val APPLOCK_TIMEOUT_GRACE_MS = 1000
}
private var appActive = false
override suspend fun registerDevice(deviceRegistration: DeviceRegistration) {
val request = createUpdateRegistrationRequest(deviceRegistration)
request.appId = APP_ID
@ -373,6 +377,25 @@ class IntegrationRepositoryImpl @Inject constructor(
localStorage.putBoolean(PREF_AUTOPLAY_VIDEO, enabled)
}
override suspend fun isAppLocked(): Boolean {
val lockEnabled = authenticationRepository.isLockEnabled()
val sessionExpireMillis = getSessionExpireMillis()
val currentMillis = System.currentTimeMillis()
val sessionExpired = currentMillis > sessionExpireMillis
val appLocked = lockEnabled && !appActive && sessionExpired
Log.d(TAG, "isAppLocked(): $appLocked. (LockEnabled: $lockEnabled, appActive: $appActive, expireMillis: $sessionExpireMillis, currentMillis: $currentMillis)")
return appLocked
}
override suspend fun setAppActive(active: Boolean) {
if (!active) {
setSessionExpireMillis(System.currentTimeMillis() + (getSessionTimeOut() * 1000) + APPLOCK_TIMEOUT_GRACE_MS)
}
Log.d(TAG, "setAppActive(): $active")
appActive = active
}
override suspend fun sessionTimeOut(value: Int) {
localStorage.putInt(PREF_SESSION_TIMEOUT, value)
}
@ -382,10 +405,11 @@ class IntegrationRepositoryImpl @Inject constructor(
}
override suspend fun setSessionExpireMillis(value: Long) {
Log.d(TAG, "setSessionExpireMillis(): $value")
localStorage.putLong(PREF_SESSION_EXPIRE, value)
}
override suspend fun getSessionExpireMillis(): Long {
private suspend fun getSessionExpireMillis(): Long {
return localStorage.getLong(PREF_SESSION_EXPIRE) ?: 0
}