mobile: local authentication (#696)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
JRoberts
2022-05-27 18:21:35 +04:00
committed by GitHub
parent 387aec8593
commit 79d9e90ab7
14 changed files with 540 additions and 78 deletions

View File

@@ -76,6 +76,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
implementation 'androidx.activity:activity-compose:1.4.0'
implementation 'androidx.fragment:fragment:1.4.1'
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
implementation "androidx.compose.material:material-icons-extended:$compose_version"
@@ -103,6 +104,9 @@ dependencies {
// Link Previews
implementation 'org.jsoup:jsoup:1.13.1'
// Biometric authentication
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

View File

@@ -5,18 +5,17 @@ import android.content.*
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
@@ -31,18 +30,21 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.connectViaUri
import chat.simplex.app.views.newchat.withUriAction
import chat.simplex.app.views.onboarding.*
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit
//import kotlinx.serialization.decodeFromString
class MainActivity: ComponentActivity() {
class MainActivity: FragmentActivity(), LifecycleEventObserver {
private val vm by viewModels<SimplexViewModel>()
private val chatController by lazy { (application as SimplexApp).chatController }
private val userAuthorized = mutableStateOf<Boolean?>(null)
private val lastLA = mutableStateOf<Long?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
// testJson()
processNotificationIntent(intent, vm.chatModel)
val m = vm.chatModel
processNotificationIntent(intent, m)
setContent {
SimpleXTheme {
Surface(
@@ -50,7 +52,7 @@ class MainActivity: ComponentActivity() {
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
MainPage(vm.chatModel)
MainPage(m, userAuthorized, ::setPerformLA, showLANotice = { m.controller.showLANotice(this) })
}
}
}
@@ -62,6 +64,47 @@ class MainActivity: ComponentActivity() {
processIntent(intent, vm.chatModel)
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
withApi {
when (event) {
Lifecycle.Event.ON_START -> {
// perform local authentication if needed
val m = vm.chatModel
val lastLAVal = lastLA.value
if (
m.controller.getPerformLA()
&& (lastLAVal == null || (System.nanoTime() - lastLAVal >= 30 * 1e+9))
) {
userAuthorized.value = false
authenticate(
generalGetString(R.string.auth_access_chats),
generalGetString(R.string.auth_log_in_using_credential),
this@MainActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
userAuthorized.value = true
lastLA.value = System.nanoTime()
}
is LAResult.Error -> laErrorToast(applicationContext, laResult.errString)
LAResult.Failed -> laFailedToast(applicationContext)
LAResult.Unavailable -> {
userAuthorized.value = true
m.performLA.value = false
m.controller.setPerformLA(false)
laUnavailableTurningOffAlert()
}
}
}
)
} else {
userAuthorized.value = true
}
}
}
}
}
private fun schedulePeriodicServiceRestartWorker() {
val workerVersion = chatController.getAutoRestartWorkerVersion()
val workPolicy = if (workerVersion == SimplexService.SERVICE_START_WORKER_VERSION) {
@@ -79,6 +122,73 @@ class MainActivity: ComponentActivity() {
Log.d(TAG, "ServiceStartWorker: Scheduling period work every ${SimplexService.SERVICE_START_WORKER_INTERVAL_MINUTES} minutes")
WorkManager.getInstance(this)?.enqueueUniquePeriodicWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC, workPolicy, work)
}
private fun setPerformLA(on: Boolean) {
val m = vm.chatModel
if (on) {
m.controller.setLANoticeShown(true)
authenticate(
generalGetString(R.string.auth_enable),
generalGetString(R.string.auth_confirm_credential),
this@MainActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
m.performLA.value = true
m.controller.setPerformLA(true)
userAuthorized.value = true
lastLA.value = System.nanoTime()
laTurnedOnAlert()
}
is LAResult.Error -> {
m.performLA.value = false
m.controller.setPerformLA(false)
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
m.performLA.value = false
m.controller.setPerformLA(false)
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
m.performLA.value = false
m.controller.setPerformLA(false)
laUnavailableInstructionAlert()
}
}
}
)
} else {
authenticate(
generalGetString(R.string.auth_disable),
generalGetString(R.string.auth_confirm_credential),
this@MainActivity,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
m.performLA.value = false
m.controller.setPerformLA(false)
}
is LAResult.Error -> {
m.performLA.value = true
m.controller.setPerformLA(true)
laErrorToast(applicationContext, laResult.errString)
}
LAResult.Failed -> {
m.performLA.value = true
m.controller.setPerformLA(true)
laFailedToast(applicationContext)
}
LAResult.Unavailable -> {
m.performLA.value = false
m.controller.setPerformLA(false)
laUnavailableTurningOffAlert()
}
}
}
)
}
}
}
class SimplexViewModel(application: Application): AndroidViewModel(application) {
@@ -87,22 +197,54 @@ class SimplexViewModel(application: Application): AndroidViewModel(application)
}
@Composable
fun MainPage(chatModel: ChatModel) {
fun MainPage(
chatModel: ChatModel,
userAuthorized: MutableState<Boolean?>,
setPerformLA: (Boolean) -> Unit,
showLANotice: () -> Unit
) {
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
var chatsAccessAuthorized by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(userAuthorized.value) {
delay(500L)
chatsAccessAuthorized = userAuthorized.value == true
}
var showAdvertiseLAAlert by remember { mutableStateOf(false) }
LaunchedEffect(showAdvertiseLAAlert) {
if (
!chatModel.controller.getLANoticeShown()
&& showAdvertiseLAAlert
&& chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete
&& chatModel.chats.isNotEmpty()
&& chatModel.activeCallInvitation.value == null
) {
showLANotice()
}
}
LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) {
if (chatModel.showAdvertiseLAUnavailableAlert.value) {
laUnavailableInstructionAlert()
}
}
Box {
val onboarding = chatModel.onboardingStage.value
val userCreated = chatModel.userCreated.value
when {
onboarding == null || userCreated == null -> SplashView()
!chatsAccessAuthorized -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated -> {
Box {
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else if (chatModel.chatId.value == null) ChatListView(chatModel)
else ChatView(chatModel)
else {
showAdvertiseLAAlert = true
if (chatModel.chatId.value == null) ChatListView(chatModel, setPerformLA = { setPerformLA(it) })
else ChatView(chatModel)
}
val invitation = chatModel.activeCallInvitation.value
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
}
} onboarding == OnboardingStage.Step1_SimpleXInfo ->
}
onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) {
SimpleXInfo(chatModel, onboarding = true)
}
@@ -180,7 +322,6 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
}
}
}
//fun testJson() {
// val str: String = """
// """.trimIndent()

View File

@@ -37,7 +37,11 @@ class ChatModel(val controller: ChatController) {
// set when app is opened via contact or invitation URI
val appOpenUrl = mutableStateOf<Uri?>(null)
// preferences
val runServiceInBackground = mutableStateOf(true)
val performLA = mutableStateOf(false)
val showAdvertiseLAUnavailableAlert = mutableStateOf(false)
// current WebRTC call
val callManager = CallManager(this)

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.views.call.*
@@ -51,6 +52,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
init {
chatModel.runServiceInBackground.value = getRunServiceInBackground()
chatModel.performLA.value = getPerformLA()
}
suspend fun startChat(user: User) {
@@ -691,6 +693,49 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
)
}
fun showLANotice(activity: FragmentActivity) {
Log.d(TAG, "showLANotice")
if (!getLANoticeShown()) {
setLANoticeShown(true)
AlertManager.shared.showAlertDialog(
title = generalGetString(R.string.la_notice_title),
text = generalGetString(R.string.la_notice_text),
confirmText = generalGetString(R.string.la_notice_turn_on),
onConfirm = {
authenticate(
generalGetString(R.string.auth_enable),
generalGetString(R.string.auth_confirm_credential),
activity,
completed = { laResult ->
when (laResult) {
LAResult.Success -> {
chatModel.performLA.value = true
setPerformLA(true)
laTurnedOnAlert()
}
is LAResult.Error -> {
chatModel.performLA.value = false
setPerformLA(false)
laErrorToast(appContext, laResult.errString)
}
LAResult.Failed -> {
chatModel.performLA.value = false
setPerformLA(false)
laFailedToast(appContext)
}
LAResult.Unavailable -> {
chatModel.performLA.value = false
setPerformLA(false)
chatModel.showAdvertiseLAUnavailableAlert.value = true
}
}
}
)
}
)
}
}
fun getAutoRestartWorkerVersion(): Int = sharedPreferences.getInt(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
fun setAutoRestartWorkerVersion(version: Int) =
@@ -738,12 +783,28 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
}
}
fun getPerformLA(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_PERFORM_LA, false)
fun setPerformLA(performLA: Boolean) =
sharedPreferences.edit()
.putBoolean(SHARED_PREFS_PERFORM_LA, performLA)
.apply()
fun getLANoticeShown(): Boolean = sharedPreferences.getBoolean(SHARED_PREFS_LA_NOTICE_SHOWN, false)
fun setLANoticeShown(shown: Boolean) =
sharedPreferences.edit()
.putBoolean(SHARED_PREFS_LA_NOTICE_SHOWN, shown)
.apply()
companion object {
private const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS"
private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion"
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
private const val SHARED_PREFS_PERFORM_LA = "PerformLA"
private const val SHARED_PREFS_LA_NOTICE_SHOWN = "LANoticeShown"
}
}

View File

@@ -22,7 +22,6 @@ import chat.simplex.app.views.chat.deleteContactDialog
import chat.simplex.app.views.chat.item.ItemAction
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
@Composable
@@ -31,10 +30,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
var showMarkRead by remember { mutableStateOf(false) }
LaunchedEffect(chat.id, chat.chatStats.unreadCount > 0) {
showMenu.value = false
launch {
delay(500L)
showMarkRead = chat.chatStats.unreadCount > 0
}
delay(500L)
showMarkRead = chat.chatStats.unreadCount > 0
}
when (chat.chatInfo) {
is ChatInfo.Direct ->

View File

@@ -64,7 +64,7 @@ fun scaffoldController(): ScaffoldController {
}
@Composable
fun ChatListView(chatModel: ChatModel) {
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
val scaffoldCtrl = scaffoldController()
if (chatModel.clearOverlays.value) {
scaffoldCtrl.collapse()
@@ -73,7 +73,7 @@ fun ChatListView(chatModel: ChatModel) {
}
BottomSheetScaffold(
scaffoldState = scaffoldCtrl.state,
drawerContent = { SettingsView(chatModel) },
drawerContent = { SettingsView(chatModel, setPerformLA) },
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
@@ -104,43 +104,6 @@ fun ChatListView(chatModel: ChatModel) {
}
}
@Composable
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(16.dp)
) {
val welcomeMsg = if (displayName != null) {
String.format(stringResource(R.string.personal_welcome), displayName)
} else stringResource(R.string.welcome)
Text(
text = welcomeMsg,
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView { scaffoldCtrl.toggleSheet() }
Row(
Modifier.padding(top = 30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(R.string.this_text_is_available_in_settings),
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.Settings,
stringResource(R.string.icon_descr_settings),
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
)
}
}
}
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
Row(

View File

@@ -0,0 +1,112 @@
package chat.simplex.app.views.helpers
import android.content.Context
import android.os.Build.VERSION.SDK_INT
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.*
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.R
sealed class LAResult {
object Success: LAResult()
class Error(val errString: CharSequence): LAResult()
object Failed: LAResult()
object Unavailable: LAResult()
}
fun authenticate(
promptTitle: String,
promptSubtitle: String,
activity: FragmentActivity,
completed: (LAResult) -> Unit
) {
when {
SDK_INT in 28..29 ->
// KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
SDK_INT > 29 ->
authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
else ->
completed(LAResult.Unavailable)
}
}
private fun authenticateWithBiometricManager(
promptTitle: String,
promptSubtitle: String,
activity: FragmentActivity,
completed: (LAResult) -> Unit,
authenticators: Int
) {
val biometricManager = BiometricManager.from(activity)
when (biometricManager.canAuthenticate(authenticators)) {
BiometricManager.BIOMETRIC_SUCCESS -> {
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(
activity,
executor,
object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString)
completed(LAResult.Error(errString))
}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
completed(LAResult.Success)
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
completed(LAResult.Failed)
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(promptTitle)
.setSubtitle(promptSubtitle)
.setAllowedAuthenticators(authenticators)
.setConfirmationRequired(false)
.build()
biometricPrompt.authenticate(promptInfo)
}
else -> {
completed(LAResult.Unavailable)
}
}
}
fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_turned_on),
generalGetString(R.string.auth_turned_on_desc)
)
fun laErrorToast(context: Context, errString: CharSequence) = Toast.makeText(
context,
if (errString.isNotEmpty()) String.format(generalGetString(R.string.auth_error_w_desc), errString) else generalGetString(R.string.auth_error),
Toast.LENGTH_SHORT
).show()
fun laFailedToast(context: Context) = Toast.makeText(
context,
generalGetString(R.string.auth_failed),
Toast.LENGTH_SHORT
).show()
fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_unavailable),
generalGetString(R.string.auth_unavailable_instruction_desc)
)
fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg(
generalGetString(R.string.auth_unavailable),
generalGetString(R.string.auth_unavailable_turning_off_desc)
)

View File

@@ -28,20 +28,25 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.SimpleXInfo
@Composable
fun SettingsView(chatModel: ChatModel) {
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) {
val user = chatModel.currentUser.value
fun setRunServiceInBackground(on: Boolean) {
chatModel.controller.setRunServiceInBackground(on)
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.setBackgroundServiceNoticeShown(false)
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
chatModel.runServiceInBackground.value = on
}
if (user != null) {
SettingsLayout(
profile = user.profile,
runServiceInBackground = chatModel.runServiceInBackground,
setRunServiceInBackground = { on ->
chatModel.controller.setRunServiceInBackground(on)
if (on && !chatModel.controller.isIgnoringBatteryOptimizations(chatModel.controller.appContext)) {
chatModel.controller.setBackgroundServiceNoticeShown(false)
}
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
chatModel.runServiceInBackground.value = on
},
setRunServiceInBackground = ::setRunServiceInBackground,
performLA = chatModel.performLA,
setPerformLA = setPerformLA,
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
@@ -58,6 +63,8 @@ fun SettingsLayout(
profile: Profile,
runServiceInBackground: MutableState<Boolean>,
setRunServiceInBackground: (Boolean) -> Unit,
performLA: MutableState<Boolean>,
setPerformLA: (Boolean) -> Unit,
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
@@ -168,7 +175,8 @@ fun SettingsLayout(
stringResource(R.string.private_notifications), Modifier
.padding(end = 24.dp)
.fillMaxWidth()
.weight(1F))
.weight(1F)
)
Switch(
checked = runServiceInBackground.value,
onCheckedChange = { setRunServiceInBackground(it) },
@@ -180,6 +188,29 @@ fun SettingsLayout(
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView() {
Icon(
Icons.Outlined.Lock,
contentDescription = stringResource(R.string.chat_lock),
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
stringResource(R.string.chat_lock), Modifier
.padding(end = 24.dp)
.fillMaxWidth()
.weight(1F)
)
Switch(
checked = performLA.value,
onCheckedChange = { setPerformLA(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
modifier = Modifier.padding(end = 8.dp)
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
@@ -246,8 +277,10 @@ fun PreviewSettingsLayout() {
profile = Profile.sampleData,
runServiceInBackground = remember { mutableStateOf(true) },
setRunServiceInBackground = {},
showModal = {{}},
showCustomModal = {{}},
performLA = remember { mutableStateOf(false) },
setPerformLA = {},
showModal = { {} },
showCustomModal = { {} },
showTerminal = {},
// showVideoChatPrototype = {}
)

View File

@@ -46,13 +46,13 @@
<string name="connection_error_auth_desc">Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью.</string>
<string name="cannot_delete_contact">Невозможно удалить контакт!</string>
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Контакт <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> не может быть удален, так как является членом групп(ы) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
<string name="error_deleting_contact">Ошибка удаления контакта</string>
<string name="error_deleting_group">Ошибка удаления группы</string>
<string name="error_deleting_contact_request">Ошибка удаления запроса</string>
<string name="error_deleting_pending_contact_connection">Ошибка удаления ожидаемого соединения</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Мгновенные уведомления</string>
<string name="private_instant_notifications">Приватные мгновенные уведомления!</string>
<string name="private_instant_notifications_disabled">Приватные уведомления выключены!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает <b>фоновый сервис <xliff:g id="appName">SimpleX</xliff:g></b>, который потребляет несколько процентов батареи в день.</string>
@@ -64,6 +64,26 @@
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> сервис</string>
<string name="simplex_service_notification_text">Приём сообщений…</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title">Блокировка SimpleX</string>
<string name="la_notice_text">Чтобы защитить вашу информацию, включите блокировку <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.\nВам будет нужно пройти аутентификацию для включения блокировки.</string>
<string name="la_notice_turn_on">Включить</string>
<!-- LocalAuthentication.kt -->
<string name="auth_turned_on">Блокировка SimpleX включена</string>
<string name="auth_turned_on_desc">Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме.</string>
<string name="auth_access_chats">Разблокировать SimpleX</string>
<string name="auth_log_in_using_credential">Пройдите аутентификацию</string>
<string name="auth_enable">Включить блокировку SimpleX</string>
<string name="auth_disable">Отключить блокировку SimpleX</string>
<string name="auth_confirm_credential">Пройдите аутентификацию</string>
<string name="auth_error">Ошибка аутентификации</string>
<string name="auth_error_w_desc">Ошибка аутентификации: <xliff:g id="desc">%1$s</xliff:g></string>
<string name="auth_failed">Ошибка аутентификации</string>
<string name="auth_unavailable">Аутентификация недоступна</string>
<string name="auth_unavailable_instruction_desc">На устройстве не включена аутентификация. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации.</string>
<string name="auth_unavailable_turning_off_desc">На устройстве выключена аутентификация. Отключение блокировки SimpleX Chat.</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Ответить</string>
<string name="share_verb">Поделиться</string>
@@ -219,8 +239,7 @@
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если вы не можете встретиться лично, вы можете <b>сосканировать QR код во время видеозвонка</b>, или ваш контакт может отправить вам ссылку.</string>
<string name="share_invitation_link">Поделиться ссылкой</string>
<string name="paste_connection_link_below_to_connect">Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта.</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Настройки</string>
<string name="your_simplex_contact_address">Ваш <xliff:g id="appName">SimpleX</xliff:g> адрес</string>
@@ -231,6 +250,7 @@
<string name="chat_with_the_founder">Соединиться с разработчиками</string>
<string name="send_us_an_email">Отправить email</string>
<string name="private_notifications">Приватные уведомления</string>
<string name="chat_lock">Блокировка SimpleX</string>
<string name="chat_console">Консоль</string>
<string name="smp_servers">SMP серверы</string>
<string name="install_simplex_chat_for_terminal"><font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> для терминала</font></string>

View File

@@ -46,13 +46,13 @@
<string name="connection_error_auth_desc">Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection.</string>
<string name="cannot_delete_contact">Can\'t delete contact!</string>
<string name="contact_cannot_be_deleted_as_they_are_in_groups">Contact <xliff:g id="contactName" example="Jane Doe">%1$s!</xliff:g> cannot be deleted, they are a member of the group(s) <xliff:g id="groups" example="[team, chess club]">%2$s</xliff:g>.</string>
<string name="icon_descr_instant_notifications">Instant notifications</string>
<string name="error_deleting_contact">Error deleting contact</string>
<string name="error_deleting_group">Error deleting group</string>
<string name="error_deleting_contact_request">Error deleting contact request</string>
<string name="error_deleting_pending_contact_connection">Error deleting pending contact connection</string>
<!-- background service notice - SimpleXAPI.kt -->
<string name="icon_descr_instant_notifications">Instant notifications</string>
<string name="private_instant_notifications">Private instant notifications!</string>
<string name="private_instant_notifications_disabled">Private notifications disabled!</string>
<string name="to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery">To preserve your privacy, instead of push notifications the app has a <b><xliff:g id="appName">SimpleX</xliff:g> background service</b> it uses a few percent of the battery per day.</string>
@@ -64,6 +64,26 @@
<string name="simplex_service_notification_title"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> service</string>
<string name="simplex_service_notification_text">Receiving messages…</string>
<!-- local authentication notice - SimpleXAPI.kt -->
<string name="la_notice_title">SimpleX Lock</string>
<string name="la_notice_text">To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled.</string>
<string name="la_notice_turn_on">Turn on</string>
<!-- LocalAuthentication.kt -->
<string name="auth_turned_on">SimpleX Lock turned on</string>
<string name="auth_turned_on_desc">You will be required to authenticate when you start or resume the app after 30 seconds in background.</string>
<string name="auth_access_chats">Access chats</string>
<string name="auth_log_in_using_credential">Log in using your credential</string>
<string name="auth_enable">Enable SimpleX Lock</string>
<string name="auth_disable">Disable SimpleX Lock</string>
<string name="auth_confirm_credential">Confirm your credential</string>
<string name="auth_error">Authentication error</string>
<string name="auth_error_w_desc">Authentication error: <xliff:g id="desc">%1$s</xliff:g></string>
<string name="auth_failed">Authentication failed</string>
<string name="auth_unavailable">Authentication unavailable</string>
<string name="auth_unavailable_instruction_desc">Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication.</string>
<string name="auth_unavailable_turning_off_desc">Device authentication is disabled. Turning off SimpleX Lock.</string>
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Reply</string>
<string name="share_verb">Share</string>
@@ -236,6 +256,7 @@
<string name="chat_with_the_founder">Connect to the developers</string>
<string name="send_us_an_email">Send us email</string>
<string name="private_notifications">Private notifications</string>
<string name="chat_lock">SimpleX Lock</string>
<string name="chat_console">Chat console</string>
<string name="smp_servers">SMP servers</string>
<string name="install_simplex_chat_for_terminal">Install <font color="#0088ff"><xliff:g id="appNameFull">SimpleX Chat</xliff:g> for terminal</font></string>

View File

@@ -12,11 +12,13 @@ struct ContentView: View {
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared
@State private var showNotificationAlert = false
@Binding var userAuthorized: Bool?
var body: some View {
ZStack {
if let step = chatModel.onboardingStage {
if case .onboardingComplete = step,
if userAuthorized == true,
case .onboardingComplete = step,
let user = chatModel.currentUser {
ZStack(alignment: .top) {
ChatListView(user: user)

View File

@@ -15,6 +15,9 @@ struct SimpleXApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var chatModel = ChatModel.shared
@Environment(\.scenePhase) var scenePhase
@State private var userAuthorized: Bool? = nil
@State private var doAuthenticate: Bool? = nil
@State private var lastLA: Double? = nil
init() {
hs_init(0, nil)
@@ -24,7 +27,7 @@ struct SimpleXApp: App {
var body: some Scene {
return WindowGroup {
ContentView()
ContentView(userAuthorized: $userAuthorized)
.environmentObject(chatModel)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
@@ -32,14 +35,54 @@ struct SimpleXApp: App {
}
.onAppear() {
initializeChat()
doAuthenticate = true
}
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase \(String(describing: scenePhase))")
setAppState(phase)
if phase == .background {
switch (phase) {
case .background:
BGManager.shared.schedule()
doAuthenticate = true
case .inactive:
authenticateUser()
case .active:
authenticateUser()
default:
break
}
}
}
}
private func authenticateUser() {
if doAuthenticate == true,
authenticationExpired() {
doAuthenticate = false
userAuthorized = false
authenticate() { laResult in
switch (laResult) {
case .success:
userAuthorized = true
lastLA = ProcessInfo.processInfo.systemUptime
case .failed:
laFailedAlert()
case .unavailable:
userAuthorized = true
laUnavailableAlert()
}
}
}
}
private func authenticationExpired() -> Bool {
if (lastLA == nil) {
return true
}
else if let lastLA = lastLA, ProcessInfo.processInfo.systemUptime - lastLA >= 30 {
return true
} else {
return false
}
}
}

View File

@@ -0,0 +1,51 @@
//
// LocalAuthenticationUtils.swift
// SimpleX (iOS)
//
// Created by Efim Poberezkin on 26.05.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import LocalAuthentication
enum LAResult {
case success
case failed(authError: String?)
case unavailable(authError: String?)
}
func authenticate(completed: @escaping (LAResult) -> Void) {
let laContext = LAContext()
var authAvailabilityError: NSError?
if laContext.canEvaluatePolicy(.deviceOwnerAuthentication, error: &authAvailabilityError) {
let reason = "Access chats"
laContext.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authError in
DispatchQueue.main.async {
if success {
completed(LAResult.success)
} else {
logger.error("authentication error: \(authError.debugDescription)")
completed(LAResult.failed(authError: authError?.localizedDescription))
}
}
}
} else {
logger.error("authentication availability error: \(authAvailabilityError.debugDescription)")
completed(LAResult.unavailable(authError: authAvailabilityError?.localizedDescription))
}
}
func laFailedAlert() {
AlertManager.shared.showAlertMsg(
title: "Authentication failed",
message: "You could not be verified; please try again."
)
}
func laUnavailableAlert() {
AlertManager.shared.showAlertMsg(
title: "Authentication unavailable",
message: "Your device is not configured for authentication."
)
}

View File

@@ -100,6 +100,8 @@
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; };
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
@@ -221,6 +223,8 @@
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; };
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
@@ -241,6 +245,7 @@
buildActionMask = 2147483647;
files = (
64A6908928376BBA0076573F /* libHSsimplex-chat-2.1.0-KOac7DFCSQz9HzISDnAYtC-ghc8.10.7.a in Frameworks */,
646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */,
64A6908528376BBA0076573F /* libgmpxx.a in Frameworks */,
64A6908728376BBA0076573F /* libgmp.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
@@ -333,6 +338,7 @@
5C764E7A279C71D4000C6508 /* Frameworks */ = {
isa = PBXGroup;
children = (
646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */,
5CDCAD6028187D7900503DA2 /* libz.tbd */,
5CDCAD5E28187D4A00503DA2 /* libiconv.tbd */,
5C764E7C279C71DB000C6508 /* libz.tbd */,
@@ -369,6 +375,7 @@
648010AA281ADD15009009B9 /* CIFileView.swift */,
6454036E2822A9750090DDFF /* ComposeFileView.swift */,
5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */,
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -714,6 +721,7 @@
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
5CB0BA962827143500B3292C /* MakeConnection.swift in Sources */,
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */,
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */,
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
@@ -950,6 +958,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -992,6 +1001,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "SimpleX--iOS--Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "SimpleX needs camera access to scan QR codes to connect to other users and for video calls.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "SimpleX uses Face ID for local authentication";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SimpleX needs microphone access for audio and video calls.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SimpleX needs access to Photo Library for saving captured and received media";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;