From 79d9e90ab73d46ef2d8cbceccc94fdf8273502b5 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 27 May 2022 18:21:35 +0400 Subject: [PATCH] mobile: local authentication (#696) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/android/app/build.gradle | 4 + .../java/chat/simplex/app/MainActivity.kt | 171 ++++++++++++++++-- .../java/chat/simplex/app/model/ChatModel.kt | 4 + .../java/chat/simplex/app/model/SimpleXAPI.kt | 61 +++++++ .../app/views/chatlist/ChatListNavLinkView.kt | 7 +- .../app/views/chatlist/ChatListView.kt | 41 +---- .../app/views/helpers/LocalAuthentication.kt | 112 ++++++++++++ .../app/views/usersettings/SettingsView.kt | 57 ++++-- .../app/src/main/res/values-ru/strings.xml | 26 ++- .../app/src/main/res/values/strings.xml | 23 ++- apps/ios/Shared/ContentView.swift | 4 +- apps/ios/Shared/SimpleXApp.swift | 47 ++++- .../Helpers/LocalAuthenticationUtils.swift | 51 ++++++ apps/ios/SimpleX.xcodeproj/project.pbxproj | 10 + 14 files changed, 540 insertions(+), 78 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt create mode 100644 apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 2fd984a2bc..d6487da9c8 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -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' diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index bfc2160398..797e51efd4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -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() private val chatController by lazy { (application as SimplexApp).chatController } + private val userAuthorized = mutableStateOf(null) + private val lastLA = mutableStateOf(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, + setPerformLA: (Boolean) -> Unit, + showLANotice: () -> Unit +) { + // this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication + var chatsAccessAuthorized by remember { mutableStateOf(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() diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 824701dae4..02a475e751 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -37,7 +37,11 @@ class ChatModel(val controller: ChatController) { // set when app is opened via contact or invitation URI val appOpenUrl = mutableStateOf(null) + + // preferences val runServiceInBackground = mutableStateOf(true) + val performLA = mutableStateOf(false) + val showAdvertiseLAUnavailableAlert = mutableStateOf(false) // current WebRTC call val callManager = CallManager(this) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 6c525e531a..58607d7700 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -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" } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 55f1931e6f..10f0fa17e9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -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 -> diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index c207f3de46..4ac07f6dd4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -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( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt new file mode 100644 index 0000000000..7c193551ff --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt @@ -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) +) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index 6431988fa4..c8dbbe88c8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -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, setRunServiceInBackground: (Boolean) -> Unit, + performLA: MutableState, + 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 = {} ) diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 5b665e6a1b..706f32ecbb 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -46,13 +46,13 @@ Возможно, ваш контакт удалил ссылку, или она уже была использована. Если это не так, то это может быть ошибкой - пожалуйста, сообщите нам об этом.\nЧтобы установить соединение, попросите ваш контакт создать еще одну ссылку и проверьте ваше соединение с сетью. Невозможно удалить контакт! Контакт %1$s! не может быть удален, так как является членом групп(ы) %2$s. - Мгновенные уведомления Ошибка удаления контакта Ошибка удаления группы Ошибка удаления запроса Ошибка удаления ожидаемого соединения + Мгновенные уведомления Приватные мгновенные уведомления! Приватные уведомления выключены! Чтобы защитить ваши личные данные, вместо уведомлений от сервера приложение запускает фоновый сервис SimpleX, который потребляет несколько процентов батареи в день. @@ -64,6 +64,26 @@ SimpleX Chat сервис Приём сообщений… + + Блокировка SimpleX + Чтобы защитить вашу информацию, включите блокировку SimpleX Chat.\nВам будет нужно пройти аутентификацию для включения блокировки. + Включить + + + Блокировка SimpleX включена + Вы будете аутентифицированы при запуске и возобновлении приложения, которое было 30 секунд в фоновом режиме. + Разблокировать SimpleX + Пройдите аутентификацию + Включить блокировку SimpleX + Отключить блокировку SimpleX + Пройдите аутентификацию + Ошибка аутентификации + Ошибка аутентификации: %1$s + Ошибка аутентификации + Аутентификация недоступна + На устройстве не включена аутентификация. Вы можете включить блокировку SimpleX в Настройках после включения аутентификации. + На устройстве выключена аутентификация. Отключение блокировки SimpleX Chat. + Ответить Поделиться @@ -219,8 +239,7 @@ Если вы не можете встретиться лично, вы можете сосканировать QR код во время видеозвонка, или ваш контакт может отправить вам ссылку. Поделиться ссылкой Чтобы соединиться, вставьте в это поле ссылку, полученную от вашего контакта. - - + Настройки Ваш SimpleX адрес @@ -231,6 +250,7 @@ Соединиться с разработчиками Отправить email Приватные уведомления + Блокировка SimpleX Консоль SMP серверы SimpleX Chat для терминала diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 0c96aff28f..b733759864 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -46,13 +46,13 @@ 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. Can\'t delete contact! Contact %1$s! cannot be deleted, they are a member of the group(s) %2$s. - Instant notifications Error deleting contact Error deleting group Error deleting contact request Error deleting pending contact connection + Instant notifications Private instant notifications! Private notifications disabled! To preserve your privacy, instead of push notifications the app has a SimpleX background service – it uses a few percent of the battery per day. @@ -64,6 +64,26 @@ SimpleX Chat service Receiving messages… + + SimpleX Lock + To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled. + Turn on + + + SimpleX Lock turned on + You will be required to authenticate when you start or resume the app after 30 seconds in background. + Access chats + Log in using your credential + Enable SimpleX Lock + Disable SimpleX Lock + Confirm your credential + Authentication error + Authentication error: %1$s + Authentication failed + Authentication unavailable + Device authentication is not enabled. You can turn on SimpleX Lock via Settings, once you enable device authentication. + Device authentication is disabled. Turning off SimpleX Lock. + Reply Share @@ -236,6 +256,7 @@ Connect to the developers Send us email Private notifications + SimpleX Lock Chat console SMP servers Install SimpleX Chat for terminal diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index e542a979fb..158392ec23 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -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) diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 8dac273ab8..246c01cc74 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -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 + } + } } diff --git a/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift new file mode 100644 index 0000000000..2cc1885640 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/LocalAuthenticationUtils.swift @@ -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." + ) +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index c028015df9..8e80c6a4be 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; + 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 = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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;