Этап 0 п.3 (доп): биометрическая привязка app password поверх шифрования
Auth-bound RSA-пара `f7_auth_master_bio` в AndroidKeystore (setUserAuthenticationRequired + setInvalidatedByBiometricEnrollment, per-use CryptoObject, префикс v2b:): ПУБЛИЧНЫЙ ключ шифрует без авторизации (в т.ч. в фоне — миграция/сохранение после входа), ПРИВАТНЫЙ расшифровывает ТОЛЬКО через BiometricPrompt+CryptoObject. Симметричный AES не подошёл бы: там auth требуется и на шифрование. Разрешение конфликта с фоновыми push/звонками: пароль расшифровывается ОДИН раз при разблокировке (AppLockGate) и живёт в памяти процесса (AppPasswordHolder, не на диске). AuthStore.load() в фоне возвращает null, пока «холодно» → существующие `?: return` в push/звонках деградируют сами; экраны Accept (CallIncomingActivity, TalkPushBridge) открывают приложение для разблокировки вместо тихого отказа. AppScaffold через loadMetadata() показывает экран блокировки, а не логин, и перечитывает сессию после разблокировки. ВЫКЛ по умолчанию (AppLockStore.isPasswordBindingEnabled, только при биометрии-без-PIN — единственный путь разблокировки прогревает holder). UI-тумблер и включение по умолчанию — ПОСЛЕ подтверждения владельцем поведения «после ребута до первой разблокировки звонки/push не работают» (как в Signal). v0.5.115 (123). Проверено сборкой assembleRelease на инфре B: APK 54.6 МБ, подпись v2+v3 OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||||
|
|
||||||
|
- 2026-07-07 | v0.5.115 (123) | Этап 0 п.3 (доп): биометрическая привязка app password поверх шифрования в покое. Auth-bound RSA-пара `f7_auth_master_bio` в AndroidKeystore (`setUserAuthenticationRequired`, `setInvalidatedByBiometricEnrollment`, per-use CryptoObject, префикс `v2b:`): ПУБЛИЧНЫЙ ключ шифрует без авторизации (в т.ч. в фоне — миграция/сохранение после входа), ПРИВАТНЫЙ расшифровывает ТОЛЬКО через BiometricPrompt+CryptoObject. Расшифровка ОДИН раз при разблокировке (AppLockGate) → `AppPasswordHolder` в памяти процесса (не на диске). Разрешает конфликт с фоновыми push/звонками: `AuthStore.load()` в фоне возвращает null «холодным» → существующие `?: return` деградируют сами; экраны Accept открывают приложение для разблокировки вместо тихого отказа. ВЫКЛ по умолчанию (`AppLockStore.isPasswordBindingEnabled`, только при биометрии-без-PIN); UI-тумблер и включение по умолчанию — ПОСЛЕ подтверждения владельцем поведения «после ребута до разблокировки звонки/push не работают» (как в Signal). Проверено сборкой assembleRelease на инфре B | контракты не менялись | средний: затрагивает хранилище пароля и путь разблокировки; по умолчанию поведение прежнее
|
||||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
|
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
|
||||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет
|
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет
|
||||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.3 (безопасность-минимум): (1) trust-all TLS отключён в release — гейт `if(!BuildConfig.DEBUG)` в UnsafeSsl + обе WebView-точки (OfficeWebViewClient, TalkCallActivity), чекбокс «доверять сертификату» скрыт в release (buildConfig включён в core:network и feature:talk); (2) allowBackup=false (переопределяет vendor true, подтверждено в собранном манифесте); (3) app password шифруется в покое ключом AndroidKeystore AES-256-GCM (KeystoreCrypto) + одноразовая миграция legacy-plaintext при load; (4) ревокация app password при logout (AppPasswordRevoker: DELETE /ocs/v2.php/core/apppassword, best-effort). Биометрическая привязка ключа — отдельная задача (конфликтует с фоновыми push/звонками, нужен кэш в памяти). Проверено сборкой: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false OK | НОВЫЙ вызов OCS: DELETE core/apppassword при logout (стандартный NC endpoint) | средний: миграция хранилища и logout затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
|
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.3 (безопасность-минимум): (1) trust-all TLS отключён в release — гейт `if(!BuildConfig.DEBUG)` в UnsafeSsl + обе WebView-точки (OfficeWebViewClient, TalkCallActivity), чекбокс «доверять сертификату» скрыт в release (buildConfig включён в core:network и feature:talk); (2) allowBackup=false (переопределяет vendor true, подтверждено в собранном манифесте); (3) app password шифруется в покое ключом AndroidKeystore AES-256-GCM (KeystoreCrypto) + одноразовая миграция legacy-plaintext при load; (4) ревокация app password при logout (AppPasswordRevoker: DELETE /ocs/v2.php/core/apppassword, best-effort). Биометрическая привязка ключа — отдельная задача (конфликтует с фоновыми push/звонками, нужен кэш в памяти). Проверено сборкой: assembleRelease 53 МБ, подпись v2+v3, allowBackup=false OK | НОВЫЙ вызов OCS: DELETE core/apppassword при logout (стандартный NC endpoint) | средний: миграция хранилища и logout затрагивают все сессии — проверить на устройстве вход/выход/перезапуск
|
||||||
|
|||||||
+10
-1
@@ -62,10 +62,19 @@ legacy WebView-оболочки `android-webview`.
|
|||||||
## Секреты (где, НЕ значения)
|
## Секреты (где, НЕ значения)
|
||||||
- В git секретов нет. Учётные данные пользователя — только на устройстве
|
- В git секретов нет. Учётные данные пользователя — только на устройстве
|
||||||
(session storage `core/auth`).
|
(session storage `core/auth`).
|
||||||
|
- App password шифруется в покое ключом AndroidKeystore AES-256-GCM (`KeystoreCrypto`,
|
||||||
|
ключ `f7_auth_master`, префикс `v1:`). Опционально — **биометрическая привязка**
|
||||||
|
(`AppLockStore.isPasswordBindingEnabled`, ВЫКЛ по умолчанию): пароль шифруется
|
||||||
|
auth-bound RSA-парой `f7_auth_master_bio` (`setUserAuthenticationRequired`, префикс
|
||||||
|
`v2b:`), расшифровывается ТОЛЬКО через BiometricPrompt+CryptoObject один раз при
|
||||||
|
разблокировке и живёт в памяти процесса (`AppPasswordHolder`, не на диске). Фоновые
|
||||||
|
сценарии (push/входящие Talk-звонки) до первой разблокировки после ребута деградируют
|
||||||
|
корректно («Разблокируйте приложение…»). Включение по умолчанию/UI-тумблер — ПОСЛЕ
|
||||||
|
подтверждения владельцем допустимости этого поведения (как в Signal).
|
||||||
- `google-services.json` (Firebase) — вне git; путь сборки документируется в
|
- `google-services.json` (Firebase) — вне git; путь сборки документируется в
|
||||||
PROJECT-STATUS.md.
|
PROJECT-STATUS.md.
|
||||||
- Release-keystore для подписи — вне git (пока не настроен, сборки debug).
|
- Release-keystore для подписи — вне git (пока не настроен, сборки debug).
|
||||||
|
|
||||||
## Версия / changelog
|
## Версия / changelog
|
||||||
- Текущее: v0.5.113 (code 121) — модуль «Задачи». История — CHANGELOG.md
|
- Текущее: v0.5.115 (code 123) — биопривязка app password (Этап 0 п.3 доп). История — CHANGELOG.md
|
||||||
и git-история `app/build.gradle`.
|
и git-история `app/build.gradle`.
|
||||||
|
|||||||
+2
-2
@@ -39,8 +39,8 @@ android {
|
|||||||
applicationId 'ru.forbion.f7cloud.mobile'
|
applicationId 'ru.forbion.f7cloud.mobile'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 122
|
versionCode 123
|
||||||
versionName '0.5.114'
|
versionName '0.5.115'
|
||||||
missingDimensionStrategy 'default', 'f7'
|
missingDimensionStrategy 'default', 'f7'
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,25 @@ class CallIncomingActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun acceptCall(launch: IncomingCallLaunch) {
|
private fun acceptCall(launch: IncomingCallLaunch) {
|
||||||
F7IncomingCallRinger.stop()
|
F7IncomingCallRinger.stop()
|
||||||
val session = AuthStore(this).load() ?: return
|
val authStore = AuthStore(this)
|
||||||
|
val session = authStore.load()
|
||||||
|
if (session == null) {
|
||||||
|
// При биопривязке пароль расшифрован только после разблокировки приложения. Если
|
||||||
|
// «холодно» (после ребута/повторной блокировки) — открываем приложение, чтобы человек
|
||||||
|
// прошёл биометрию, а не молча роняем звонок.
|
||||||
|
if (authStore.isPasswordLocked()) {
|
||||||
|
android.widget.Toast.makeText(
|
||||||
|
this,
|
||||||
|
"Разблокируйте приложение, чтобы принять звонок",
|
||||||
|
android.widget.Toast.LENGTH_LONG,
|
||||||
|
).show()
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||||
|
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
startActivity(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
|
F7IncomingCallQueue.dismissAndShowNext(this, launch.roomToken)
|
||||||
TalkNativeCallLauncher.launchIncomingCall(
|
TalkNativeCallLauncher.launchIncomingCall(
|
||||||
this,
|
this,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package ru.forbion.f7cloud.mobile.ui
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
@@ -51,6 +52,9 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import ru.forbion.f7cloud.core.auth.AppLockStore
|
import ru.forbion.f7cloud.core.auth.AppLockStore
|
||||||
|
import ru.forbion.f7cloud.core.auth.AppPasswordHolder
|
||||||
|
import ru.forbion.f7cloud.core.auth.AuthStore
|
||||||
|
import ru.forbion.f7cloud.core.auth.KeystoreCrypto
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
||||||
@@ -63,6 +67,7 @@ private enum class AppLockSetupStage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val PIN_LENGTH = 4
|
private const val PIN_LENGTH = 4
|
||||||
|
private const val TAG = "AppLockGate"
|
||||||
|
|
||||||
private fun biometricAuthenticators(): Int =
|
private fun biometricAuthenticators(): Int =
|
||||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||||
@@ -76,6 +81,7 @@ private fun canUseBiometric(context: android.content.Context): Boolean =
|
|||||||
fun AppLockGate(
|
fun AppLockGate(
|
||||||
lockStore: AppLockStore,
|
lockStore: AppLockStore,
|
||||||
unlockNonce: Int = 0,
|
unlockNonce: Int = 0,
|
||||||
|
onUnlocked: () -> Unit = {},
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
if (!lockStore.isEnabled()) {
|
if (!lockStore.isEnabled()) {
|
||||||
@@ -85,10 +91,17 @@ fun AppLockGate(
|
|||||||
var unlocked by remember { mutableStateOf(false) }
|
var unlocked by remember { mutableStateOf(false) }
|
||||||
var lockSession by remember { mutableIntStateOf(0) }
|
var lockSession by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// При повторной блокировкe забываем расшифрованный пароль из памяти: заблокированное
|
||||||
|
// приложение не должно держать секрет резидентно (фоновые сценарии деградируют до разблокировки).
|
||||||
|
fun relock() {
|
||||||
|
AppPasswordHolder.clear()
|
||||||
|
unlocked = false
|
||||||
|
lockSession++
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (lockStore.consumeColdStart()) {
|
if (lockStore.consumeColdStart()) {
|
||||||
unlocked = false
|
relock()
|
||||||
lockSession++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +112,8 @@ fun AppLockGate(
|
|||||||
Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded()
|
Lifecycle.Event.ON_STOP -> lockStore.markBackgrounded()
|
||||||
Lifecycle.Event.ON_START -> {
|
Lifecycle.Event.ON_START -> {
|
||||||
when {
|
when {
|
||||||
lockStore.consumeColdStart() -> {
|
lockStore.consumeColdStart() -> relock()
|
||||||
unlocked = false
|
lockStore.shouldRequireUnlock() -> relock()
|
||||||
lockSession++
|
|
||||||
}
|
|
||||||
lockStore.shouldRequireUnlock() -> {
|
|
||||||
unlocked = false
|
|
||||||
lockSession++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -133,6 +140,7 @@ fun AppLockGate(
|
|||||||
onUnlocked = {
|
onUnlocked = {
|
||||||
lockStore.clearBackgroundMarker()
|
lockStore.clearBackgroundMarker()
|
||||||
unlocked = true
|
unlocked = true
|
||||||
|
onUnlocked()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -542,6 +550,12 @@ private fun AppLockUnlockScreen(
|
|||||||
val biometricOnly = lockStore.isBiometricOnly()
|
val biometricOnly = lockStore.isBiometricOnly()
|
||||||
val biometricAvailable = remember { canUseBiometric(context) }
|
val biometricAvailable = remember { canUseBiometric(context) }
|
||||||
|
|
||||||
|
// Биометрическая привязка пароля: при разблокировке расшифровываем app password через
|
||||||
|
// CryptoObject и кладём в память (AppPasswordHolder), чтобы фоновые сценарии его читали.
|
||||||
|
val authStore = remember { AuthStore(context) }
|
||||||
|
val bindingEnabled = lockStore.isPasswordBindingEnabled()
|
||||||
|
val boundCipherText = remember(lockSession) { authStore.boundPasswordCiphertext() }
|
||||||
|
|
||||||
fun verifyPinInput() {
|
fun verifyPinInput() {
|
||||||
if (lockStore.verifyPin(pin)) {
|
if (lockStore.verifyPin(pin)) {
|
||||||
onUnlocked()
|
onUnlocked()
|
||||||
@@ -572,12 +586,39 @@ private fun AppLockUnlockScreen(
|
|||||||
error = "Биометрия недоступна"
|
error = "Биометрия недоступна"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Режим привязки: готовим Cipher для расшифровки пароля. Если ключ инвалидирован (сменилась
|
||||||
|
// биометрия устройства) — привязку сбрасываем, чтобы не запереть пользователя навсегда;
|
||||||
|
// приложение попросит войти заново (пароль восстановить нельзя).
|
||||||
|
val useCrypto = bindingEnabled && boundCipherText != null
|
||||||
|
val decryptCipher = if (useCrypto) {
|
||||||
|
runCatching { KeystoreCrypto.initBoundDecryptCipher(boundCipherText) }.getOrElse {
|
||||||
|
Log.w(TAG, "Bound key invalidated, resetting binding", it)
|
||||||
|
lockStore.setPasswordBinding(false)
|
||||||
|
authStore.clear()
|
||||||
|
onUnlocked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
val executor = ContextCompat.getMainExecutor(context)
|
val executor = ContextCompat.getMainExecutor(context)
|
||||||
val prompt = BiometricPrompt(
|
val prompt = BiometricPrompt(
|
||||||
host,
|
host,
|
||||||
executor,
|
executor,
|
||||||
object : BiometricPrompt.AuthenticationCallback() {
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
val cipher = result.cryptoObject?.cipher
|
||||||
|
if (useCrypto && cipher != null) {
|
||||||
|
val ok = runCatching {
|
||||||
|
AppPasswordHolder.set(
|
||||||
|
KeystoreCrypto.finishBoundDecrypt(cipher, boundCipherText),
|
||||||
|
)
|
||||||
|
}.isSuccess
|
||||||
|
if (!ok) {
|
||||||
|
error = "Не удалось расшифровать пароль, войдите заново"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
onUnlocked()
|
onUnlocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,14 +635,23 @@ private fun AppLockUnlockScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
prompt.authenticate(
|
// CryptoObject требует СИЛЬНОЙ биометрии (Class 3); без привязки допускаем и weak.
|
||||||
BiometricPrompt.PromptInfo.Builder()
|
val authenticators = if (useCrypto) {
|
||||||
.setTitle("Разблокировка F7cloud")
|
BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||||
.setSubtitle("Прикоснитесь к сканеру отпечатка")
|
} else {
|
||||||
.setAllowedAuthenticators(biometricAuthenticators())
|
biometricAuthenticators()
|
||||||
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
|
}
|
||||||
.build(),
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
)
|
.setTitle("Разблокировка F7cloud")
|
||||||
|
.setSubtitle("Прикоснитесь к сканеру отпечатка")
|
||||||
|
.setAllowedAuthenticators(authenticators)
|
||||||
|
.setNegativeButtonText(if (biometricOnly) "Отмена" else "Ввести PIN")
|
||||||
|
.build()
|
||||||
|
if (decryptCipher != null) {
|
||||||
|
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(decryptCipher))
|
||||||
|
} else {
|
||||||
|
prompt.authenticate(promptInfo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(lockSession, lockStore.useBiometric(), activity) {
|
LaunchedEffect(lockSession, lockStore.useBiometric(), activity) {
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ fun AppScaffold(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val authStore = remember { AuthStore(context) }
|
val authStore = remember { AuthStore(context) }
|
||||||
var session by remember { mutableStateOf(authStore.load()) }
|
// loadMetadata (не load): при биопривязке до разблокировки пароля нет, но сессия ЕСТЬ —
|
||||||
|
// показываем экран блокировки, а не логин. Полную сессию с паролем берём после разблокировки.
|
||||||
|
var session by remember { mutableStateOf(authStore.loadMetadata()) }
|
||||||
var activeTab by rememberSaveable(
|
var activeTab by rememberSaveable(
|
||||||
saver = Saver(
|
saver = Saver(
|
||||||
save = { state -> state.value.name },
|
save = { state -> state.value.name },
|
||||||
@@ -228,7 +230,13 @@ fun AppScaffold(
|
|||||||
onDismiss = { showAppLockSetup = false },
|
onDismiss = { showAppLockSetup = false },
|
||||||
onLockConfigured = { lockUnlockNonce++ },
|
onLockConfigured = { lockUnlockNonce++ },
|
||||||
)
|
)
|
||||||
AppLockGate(lockStore = lockStore, unlockNonce = lockUnlockNonce) {
|
AppLockGate(
|
||||||
|
lockStore = lockStore,
|
||||||
|
unlockNonce = lockUnlockNonce,
|
||||||
|
// После разблокировки (в т.ч. биометрией с CryptoObject) пароль уже в памяти —
|
||||||
|
// перечитываем полную сессию, чтобы сетевые вызовы получили app password.
|
||||||
|
onUnlocked = { authStore.load()?.let { session = it } },
|
||||||
|
) {
|
||||||
fun applyAppLink(target: AppLinkTarget) {
|
fun applyAppLink(target: AppLinkTarget) {
|
||||||
activeTab = target.tab
|
activeTab = target.tab
|
||||||
pendingTalkRoomToken = target.talkRoomToken
|
pendingTalkRoomToken = target.talkRoomToken
|
||||||
|
|||||||
@@ -66,12 +66,27 @@ class AppLockStore(context: Context) {
|
|||||||
|
|
||||||
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
|
fun isBiometricOnly(): Boolean = prefs.getBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Биометрическая привязка app password (auth-bound ключ + кэш в памяти). ВЫКЛ по умолчанию.
|
||||||
|
* Разрешена только при биометрии-без-PIN: единственный путь разблокировки — биометрия, значит
|
||||||
|
* при каждой разблокировке гарантированно прогревается [AppPasswordHolder] через CryptoObject.
|
||||||
|
* Меняет фоновое поведение (звонки/push после ребута до первой разблокировки не работают) —
|
||||||
|
* включать через настройку только после подтверждения владельцем.
|
||||||
|
*/
|
||||||
|
fun isPasswordBindingEnabled(): Boolean =
|
||||||
|
prefs.getBoolean(KEY_BIND_PASSWORD, false) && isEnabled() && isBiometricOnly()
|
||||||
|
|
||||||
|
fun setPasswordBinding(enabled: Boolean) {
|
||||||
|
prefs.edit().putBoolean(KEY_BIND_PASSWORD, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun disable() {
|
fun disable() {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putBoolean(KEY_ENABLED, false)
|
.putBoolean(KEY_ENABLED, false)
|
||||||
.remove(KEY_PIN_HASH)
|
.remove(KEY_PIN_HASH)
|
||||||
.putBoolean(KEY_BIOMETRIC, false)
|
.putBoolean(KEY_BIOMETRIC, false)
|
||||||
.putBoolean(KEY_BIOMETRIC_ONLY, false)
|
.putBoolean(KEY_BIOMETRIC_ONLY, false)
|
||||||
|
.putBoolean(KEY_BIND_PASSWORD, false)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +110,7 @@ class AppLockStore(context: Context) {
|
|||||||
private const val KEY_BIOMETRIC_ONLY = "biometric_only"
|
private const val KEY_BIOMETRIC_ONLY = "biometric_only"
|
||||||
private const val KEY_PIN_HASH = "pin_hash"
|
private const val KEY_PIN_HASH = "pin_hash"
|
||||||
private const val KEY_SETUP_OFFERED = "setup_offered"
|
private const val KEY_SETUP_OFFERED = "setup_offered"
|
||||||
|
private const val KEY_BIND_PASSWORD = "bind_password"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var coldStartPending = true
|
private var coldStartPending = true
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process-memory кэш расшифрованного app password для режима биометрической привязки
|
||||||
|
* (см. [KeystoreCrypto] auth-bound ключ и [AppLockStore.isPasswordBindingEnabled]).
|
||||||
|
*
|
||||||
|
* Когда пароль зашифрован ключом, требующим биометрию, его нельзя расшифровать в фоне
|
||||||
|
* (push, входящие звонки, холодный старт) — там некому пройти BiometricPrompt. Поэтому пароль
|
||||||
|
* расшифровывается ОДИН раз при разблокировке приложения (CryptoObject в AppLockGate) и живёт
|
||||||
|
* здесь, в памяти процесса. НИКОГДА не сериализуется на диск.
|
||||||
|
*
|
||||||
|
* «Тёплый» = пароль в памяти (приложение разблокировано после старта/ребута). «Холодный» =
|
||||||
|
* процесс только поднялся или приложение снова заблокировано → фоновые сценарии деградируют
|
||||||
|
* корректно («Разблокируйте приложение, чтобы принимать звонки»).
|
||||||
|
*
|
||||||
|
* При выключенной привязке (дефолт) holder не используется вовсе.
|
||||||
|
*/
|
||||||
|
object AppPasswordHolder {
|
||||||
|
@Volatile
|
||||||
|
private var password: String? = null
|
||||||
|
|
||||||
|
/** True, если пароль расшифрован и лежит в памяти (приложение разблокировано). */
|
||||||
|
val isWarm: Boolean
|
||||||
|
get() = password != null
|
||||||
|
|
||||||
|
/** Кладёт расшифрованный пароль в память (вызывается после успешной биометрии при разблокировке). */
|
||||||
|
fun set(value: String) {
|
||||||
|
password = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает пароль из памяти или null, если «холодно» (не разблокировано в этом процессе). */
|
||||||
|
fun get(): String? = password
|
||||||
|
|
||||||
|
/** Забывает пароль (logout или повторная блокировка приложения). */
|
||||||
|
fun clear() {
|
||||||
|
password = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,23 @@ package ru.forbion.f7cloud.core.auth
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
class AuthStore(context: Context) {
|
class AuthStore(context: Context) {
|
||||||
private val prefs = context.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
|
private val appContext = context.applicationContext
|
||||||
|
private val prefs = appContext.getSharedPreferences("f7_auth", Context.MODE_PRIVATE)
|
||||||
|
private val lockStore by lazy { AppLockStore(appContext) }
|
||||||
|
|
||||||
fun save(session: AuthSession) {
|
fun save(session: AuthSession) {
|
||||||
|
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto).
|
||||||
|
// При включённой биометрической привязке — auth-bound ключом (расшифровка требует
|
||||||
|
// биометрии), иначе — обычным AES-256-GCM.
|
||||||
|
val encrypted = if (lockStore.isPasswordBindingEnabled()) {
|
||||||
|
KeystoreCrypto.encryptAuthBound(session.appPassword)
|
||||||
|
} else {
|
||||||
|
KeystoreCrypto.encrypt(session.appPassword)
|
||||||
|
}
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("server_url", session.serverUrl.trimEnd('/'))
|
.putString("server_url", session.serverUrl.trimEnd('/'))
|
||||||
.putString("username", session.username.trim())
|
.putString("username", session.username.trim())
|
||||||
// app password шифруется в покое ключом из AndroidKeystore (см. KeystoreCrypto)
|
.putString("app_password", encrypted)
|
||||||
.putString("app_password", KeystoreCrypto.encrypt(session.appPassword))
|
|
||||||
.putBoolean("trust_all_certs", session.trustAllCerts)
|
.putBoolean("trust_all_certs", session.trustAllCerts)
|
||||||
.putString("dav_user_id", session.davUserId)
|
.putString("dav_user_id", session.davUserId)
|
||||||
.apply()
|
.apply()
|
||||||
@@ -21,13 +30,19 @@ class AuthStore(context: Context) {
|
|||||||
val username = prefs.getString("username", null) ?: return null
|
val username = prefs.getString("username", null) ?: return null
|
||||||
val storedPassword = prefs.getString("app_password", null) ?: return null
|
val storedPassword = prefs.getString("app_password", null) ?: return null
|
||||||
|
|
||||||
val appPassword = if (KeystoreCrypto.isEncrypted(storedPassword)) {
|
val appPassword = when {
|
||||||
// Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных
|
KeystoreCrypto.isAuthBound(storedPassword) -> {
|
||||||
// данных устройства) — трактуем как «сессии нет», пользователь войдёт заново.
|
// Привязано к биометрии: расшифровать в фоне нельзя. Берём из памяти, если
|
||||||
runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null
|
// приложение уже разблокировано в этом процессе; иначе «холодно» → сессии нет,
|
||||||
} else {
|
// фоновый вызывающий деградирует (`?: return`). Прогрев — в AppLockGate.
|
||||||
// Legacy: пароль сохранён plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
|
AppPasswordHolder.get() ?: return null
|
||||||
storedPassword
|
}
|
||||||
|
KeystoreCrypto.isEncrypted(storedPassword) -> {
|
||||||
|
// Расшифровка может упасть, если Keystore-ключ инвалидирован (сброс учётных
|
||||||
|
// данных устройства) — трактуем как «сессии нет», пользователь войдёт заново.
|
||||||
|
runCatching { KeystoreCrypto.decrypt(storedPassword) }.getOrNull() ?: return null
|
||||||
|
}
|
||||||
|
else -> storedPassword // Legacy plaintext (до шифрования) — используем и МИГРИРУЕМ ниже.
|
||||||
}
|
}
|
||||||
|
|
||||||
val session = AuthSession(
|
val session = AuthSession(
|
||||||
@@ -37,13 +52,43 @@ class AuthStore(context: Context) {
|
|||||||
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
|
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
|
||||||
davUserId = prefs.getString("dav_user_id", null),
|
davUserId = prefs.getString("dav_user_id", null),
|
||||||
)
|
)
|
||||||
if (!KeystoreCrypto.isEncrypted(storedPassword)) {
|
if (!KeystoreCrypto.isEncrypted(storedPassword) && !KeystoreCrypto.isAuthBound(storedPassword)) {
|
||||||
runCatching { save(session) } // одноразовая миграция plaintext → ciphertext
|
runCatching { save(session) } // одноразовая миграция plaintext → ciphertext
|
||||||
}
|
}
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метаданные сессии для гейта блокировки: возвращает сессию даже когда пароль привязан и
|
||||||
|
* ещё не расшифрован (поле [AuthSession.appPassword] тогда пустое). null — только если входа
|
||||||
|
* нет вовсе. Отличает «залогинен, но заблокирован» от «не залогинен» для экрана блокировки.
|
||||||
|
*/
|
||||||
|
fun loadMetadata(): AuthSession? {
|
||||||
|
val serverUrl = prefs.getString("server_url", null) ?: return null
|
||||||
|
val username = prefs.getString("username", null) ?: return null
|
||||||
|
prefs.getString("app_password", null) ?: return null
|
||||||
|
return load() ?: AuthSession(
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
username = username,
|
||||||
|
appPassword = "",
|
||||||
|
trustAllCerts = prefs.getBoolean("trust_all_certs", false),
|
||||||
|
davUserId = prefs.getString("dav_user_id", null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пароль привязан к биометрии, но ещё не расшифрован в память (залогинен, но заблокирован). */
|
||||||
|
fun isPasswordLocked(): Boolean {
|
||||||
|
val stored = prefs.getString("app_password", null) ?: return false
|
||||||
|
return KeystoreCrypto.isAuthBound(stored) && !AppPasswordHolder.isWarm
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сырой auth-bound шифртекст пароля для расшифровки через CryptoObject (или null). */
|
||||||
|
fun boundPasswordCiphertext(): String? =
|
||||||
|
prefs.getString("app_password", null)?.takeIf { KeystoreCrypto.isAuthBound(it) }
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
|
AppPasswordHolder.clear()
|
||||||
|
KeystoreCrypto.deleteBoundKey()
|
||||||
prefs.edit().clear().apply()
|
prefs.edit().clear().apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
package ru.forbion.f7cloud.core.auth
|
package ru.forbion.f7cloud.core.auth
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.spec.MGF1ParameterSpec
|
||||||
|
import java.security.spec.X509EncodedKeySpec
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.OAEPParameterSpec
|
||||||
|
import javax.crypto.spec.PSource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Шифрование секретов в покое ключом из AndroidKeystore (AES-256-GCM).
|
* Шифрование секретов в покое ключом из AndroidKeystore (AES-256-GCM).
|
||||||
@@ -31,8 +39,19 @@ object KeystoreCrypto {
|
|||||||
private const val TAG_BITS = 128
|
private const val TAG_BITS = 128
|
||||||
const val PREFIX = "v1:"
|
const val PREFIX = "v1:"
|
||||||
|
|
||||||
|
// --- Биометрически привязанный (auth-bound) вариант ---------------------------------------
|
||||||
|
// RSA-пара: ПУБЛИЧНЫЙ ключ шифрует без авторизации (можно в фоне — миграция/save после входа),
|
||||||
|
// ПРИВАТНЫЙ требует биометрию (setUserAuthenticationRequired) и расшифровывает ТОЛЬКО через
|
||||||
|
// BiometricPrompt+CryptoObject. Симметричный AES не подошёл бы: там auth нужен и на шифрование.
|
||||||
|
private const val BIO_KEY_ALIAS = "f7_auth_master_bio"
|
||||||
|
private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding"
|
||||||
|
const val PREFIX_BIO = "v2b:"
|
||||||
|
|
||||||
fun isEncrypted(value: String?): Boolean = value != null && value.startsWith(PREFIX)
|
fun isEncrypted(value: String?): Boolean = value != null && value.startsWith(PREFIX)
|
||||||
|
|
||||||
|
/** True для шифртекста, привязанного к биометрии (расшифровка требует BiometricPrompt). */
|
||||||
|
fun isAuthBound(value: String?): Boolean = value != null && value.startsWith(PREFIX_BIO)
|
||||||
|
|
||||||
/** Шифрует строку; результат — с префиксом [PREFIX]. */
|
/** Шифрует строку; результат — с префиксом [PREFIX]. */
|
||||||
fun encrypt(plaintext: String): String {
|
fun encrypt(plaintext: String): String {
|
||||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
@@ -76,4 +95,87 @@ object KeystoreCrypto {
|
|||||||
)
|
)
|
||||||
return generator.generateKey()
|
return generator.generateKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auth-bound (RSA) ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Шифрует пароль публичным ключом привязанной пары. Авторизация НЕ требуется (публичный ключ),
|
||||||
|
* поэтому вызывается откуда угодно, включая фон. Результат — с префиксом [PREFIX_BIO].
|
||||||
|
*/
|
||||||
|
fun encryptAuthBound(plaintext: String): String {
|
||||||
|
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
|
||||||
|
// Публичный ключ из Keystore переупаковываем через дефолтный провайдер: иначе Cipher из
|
||||||
|
// AndroidKeyStore на init может «прицепить» auth-требование и к шифрованию.
|
||||||
|
val publicKey = getOrCreateBioEntry().certificate.publicKey
|
||||||
|
val unrestricted = KeyFactory.getInstance(publicKey.algorithm)
|
||||||
|
.generatePublic(X509EncodedKeySpec(publicKey.encoded))
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, unrestricted, oaepParams())
|
||||||
|
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
return PREFIX_BIO + Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Готовит Cipher для расшифровки привязанного шифртекста (DECRYPT + приватный ключ). Его нужно
|
||||||
|
* обернуть в `BiometricPrompt.CryptoObject`, показать промпт и на успехе вызвать
|
||||||
|
* [finishBoundDecrypt]. Бросает, если ключ инвалидирован (сменилась биометрия/сброс) —
|
||||||
|
* вызывающий трактует как «нужен повторный вход».
|
||||||
|
*/
|
||||||
|
fun initBoundDecryptCipher(stored: String): Cipher {
|
||||||
|
require(isAuthBound(stored)) { "not an auth-bound value" }
|
||||||
|
val privateKey = getOrCreateBioEntry().privateKey as PrivateKey
|
||||||
|
val cipher = Cipher.getInstance(RSA_TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams())
|
||||||
|
return cipher
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Завершает расшифровку после успешной биометрии — [cipher] должен прийти из
|
||||||
|
* `result.cryptoObject.cipher`, авторизованного пользователем.
|
||||||
|
*/
|
||||||
|
fun finishBoundDecrypt(cipher: Cipher, stored: String): String {
|
||||||
|
require(isAuthBound(stored)) { "not an auth-bound value" }
|
||||||
|
val ct = Base64.decode(stored.removePrefix(PREFIX_BIO), Base64.NO_WRAP)
|
||||||
|
return String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет привязанный ключ (при отключении привязки/повторном входе). */
|
||||||
|
fun deleteBoundKey() {
|
||||||
|
runCatching {
|
||||||
|
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }.deleteEntry(BIO_KEY_ALIAS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAEP с MGF1-SHA1 задаём явно на обеих сторонах: AndroidKeyStore внутри использует SHA-1 для
|
||||||
|
// MGF1 даже при digest SHA-256 — без этого расшифровка падает несовпадением параметров.
|
||||||
|
private fun oaepParams(): OAEPParameterSpec =
|
||||||
|
OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
|
||||||
|
|
||||||
|
private fun getOrCreateBioEntry(): KeyStore.PrivateKeyEntry {
|
||||||
|
val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
(ks.getEntry(BIO_KEY_ALIAS, null) as? KeyStore.PrivateKeyEntry)?.let { return it }
|
||||||
|
val generator = KeyPairGenerator.getInstance(
|
||||||
|
KeyProperties.KEY_ALGORITHM_RSA,
|
||||||
|
ANDROID_KEYSTORE,
|
||||||
|
)
|
||||||
|
val builder = KeyGenParameterSpec.Builder(
|
||||||
|
BIO_KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setKeySize(2048)
|
||||||
|
.setDigests(KeyProperties.DIGEST_SHA256)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
|
||||||
|
.setUserAuthenticationRequired(true)
|
||||||
|
// Смена/добавление биометрии инвалидирует ключ → пароль недоступен → повторный вход.
|
||||||
|
.setInvalidatedByBiometricEnrollment(true)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
// Окно валидности 0 = авторизация на КАЖДОЕ использование (через CryptoObject),
|
||||||
|
// только сильная биометрия (Class 3) — обязательна для CryptoObject в Keystore.
|
||||||
|
builder.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
|
||||||
|
}
|
||||||
|
// На API < 30 отсутствие setUserAuthenticationValidityDurationSeconds(>0) уже означает
|
||||||
|
// per-use auth, совместимую с CryptoObject.
|
||||||
|
generator.initialize(builder.build())
|
||||||
|
generator.generateKeyPair()
|
||||||
|
return ks.getEntry(BIO_KEY_ALIAS, null) as KeyStore.PrivateKeyEntry
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -14,9 +14,20 @@ object TalkPushBridge {
|
|||||||
private const val TAG = "TalkPushBridge"
|
private const val TAG = "TalkPushBridge"
|
||||||
|
|
||||||
fun launchAcceptedCall(context: Context, acceptUrl: String) {
|
fun launchAcceptedCall(context: Context, acceptUrl: String) {
|
||||||
val session = AuthStore(context).load()
|
val authStore = AuthStore(context)
|
||||||
|
val session = authStore.load()
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
Log.w(TAG, "No session for accepted call")
|
// Биопривязка + «холодно»: пароль ещё не разблокирован. Открываем приложение на экране
|
||||||
|
// блокировки, чтобы пользователь прошёл биометрию, вместо тихого отказа.
|
||||||
|
if (authStore.isPasswordLocked()) {
|
||||||
|
Log.w(TAG, "Password locked, opening app to unlock before accepting call")
|
||||||
|
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let {
|
||||||
|
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "No session for accepted call")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
TalkNativeCallLauncher.launchIncomingCall(context, session, acceptUrl)
|
TalkNativeCallLauncher.launchIncomingCall(context, session, acceptUrl)
|
||||||
|
|||||||
Reference in New Issue
Block a user