diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/App.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/App.kt new file mode 100644 index 0000000000..2079abed0c --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/App.kt @@ -0,0 +1,213 @@ +package chat.simplex.app + +import android.os.SystemClock +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.ui.theme.SimpleButton +import chat.simplex.app.views.SplashView +import chat.simplex.app.views.call.ActiveCallView +import chat.simplex.app.views.call.IncomingCallAlertView +import chat.simplex.app.views.chat.ChatView +import chat.simplex.app.views.chatlist.ChatListView +import chat.simplex.app.views.chatlist.ShareListView +import chat.simplex.app.views.database.DatabaseErrorView +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.onboarding.* +import chat.simplex.app.views.usersettings.LAMode +import chat.simplex.app.views.usersettings.laUnavailableInstructionAlert +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@Composable +fun MainPage( + chatModel: ChatModel, + userAuthorized: MutableState, + laFailed: MutableState, + destroyedAfterBackPress: MutableState, + runAuthenticate: () -> Unit, + setPerformLA: (Boolean) -> Unit, + showLANotice: () -> Unit +) { + var showChatDatabaseError by rememberSaveable { + mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null) + } + LaunchedEffect(chatModel.chatDbStatus.value) { + showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null + } + + var showAdvertiseLAAlert by remember { mutableStateOf(false) } + LaunchedEffect(showAdvertiseLAAlert) { + if ( + !chatModel.controller.appPrefs.laNoticeShown.get() + && showAdvertiseLAAlert + && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete + && chatModel.chats.isNotEmpty() + && chatModel.activeCallInvitation.value == null + ) { + showLANotice() + } + } + LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) { + if (chatModel.showAdvertiseLAUnavailableAlert.value) { + laUnavailableInstructionAlert() + } + } + LaunchedEffect(chatModel.clearOverlays.value) { + if (chatModel.clearOverlays.value) { + ModalManager.shared.closeModals() + chatModel.clearOverlays.value = false + } + } + + @Composable + fun AuthView() { + Surface(color = MaterialTheme.colors.background) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + laFailed.value = false + runAuthenticate() + } + ) + } + } + } + + Box { + val onboarding = chatModel.onboardingStage.value + val userCreated = chatModel.userCreated.value + var showInitializationView by remember { mutableStateOf(false) } + when { + chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView() + showChatDatabaseError -> { + chatModel.chatDbStatus.value?.let { + DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) + } + } + onboarding == null || userCreated == null -> SplashView() + onboarding == OnboardingStage.OnboardingComplete && userCreated -> { + Box { + showAdvertiseLAAlert = true + BoxWithConstraints { + var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) } + val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) } + Box( + Modifier + .graphicsLayer { + translationX = -offset.value.dp.toPx() + } + ) { + val stopped = chatModel.chatRunning.value == false + if (chatModel.sharedContent.value == null) + ChatListView(chatModel, setPerformLA, stopped) + else + ShareListView(chatModel, stopped) + } + val scope = rememberCoroutineScope() + val onComposed: () -> Unit = { + scope.launch { + offset.animateTo( + if (chatModel.chatId.value == null) 0f else maxWidth.value, + chatListAnimationSpec() + ) + if (offset.value == 0f) { + currentChatId = null + } + } + } + LaunchedEffect(Unit) { + launch { + snapshotFlow { chatModel.chatId.value } + .distinctUntilChanged() + .collect { + if (it != null) currentChatId = it + else onComposed() + } + } + } + Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ { + currentChatId?.let { + ChatView(it, chatModel, onComposed) + } + } + } + } + } + onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true) + onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} + onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) + onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) + } + ModalManager.shared.showInView() + val unauthorized = remember { derivedStateOf { userAuthorized.value != true } } + if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) { + LaunchedEffect(Unit) { + // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request + // while the screen moves to a launcher. Detect it and prevent showing the auth + if (!(destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { + runAuthenticate() + } + } + if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) { + AuthView() + } else { + SplashView() + } + } else if (chatModel.showCallView.value) { + ActiveCallView(chatModel) + } + ModalManager.shared.showPasscodeInView() + val invitation = chatModel.activeCallInvitation.value + if (invitation != null) IncomingCallAlertView(invitation, chatModel) + AlertManager.shared.showInView() + + LaunchedEffect(Unit) { + delay(1000) + if (chatModel.chatDbStatus.value == null) { + showInitializationView = true + } + } + } + + DisposableEffectOnRotate { + // When using lock delay = 0 and screen rotates, the app will be locked which is not useful. + // Let's prolong the unlocked period to 3 sec for screen rotation to take place + if (chatModel.controller.appPrefs.laLockDelay.get() == 0) { + AppLock.enteredBackground.value = SystemClock.elapsedRealtime() + 3000 + } + } +} + +@Composable +private fun InitializationView() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + Modifier + .padding(bottom = DEFAULT_PADDING) + .size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 2.5.dp + ) + Text(stringResource(MR.strings.opening_database)) + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/AppLock.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/AppLock.kt new file mode 100644 index 0000000000..7cd680592f --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/AppLock.kt @@ -0,0 +1,252 @@ +package chat.simplex.app + +import android.util.Log +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.SharedPreference +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.localauth.SetAppPasscodeView +import chat.simplex.app.views.usersettings.* +import chat.simplex.res.MR +import kotlinx.coroutines.* + +object AppLock { + /** + * We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user + * clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values) + * See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user + * */ + val userAuthorized = mutableStateOf(null) + val enteredBackground = mutableStateOf(null) + + // Remember result and show it after orientation change + val laFailed = mutableStateOf(false) + val destroyedAfterBackPress = mutableStateOf(false) + + fun clearAuthState() { + userAuthorized.value = null + enteredBackground.value = null + } + + fun showLANotice(laNoticeShown: SharedPreference) { + Log.d(TAG, "showLANotice") + if (!laNoticeShown.get()) { + laNoticeShown.set(true) + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.la_notice_title_simplex_lock), + text = generalGetString(MR.strings.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled), + confirmText = generalGetString(MR.strings.la_notice_turn_on), + onConfirm = { + withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager + showChooseLAMode(laNoticeShown) + } + } + ) + } + } + + private fun showChooseLAMode(laNoticeShown: SharedPreference) { + Log.d(TAG, "showLANotice") + laNoticeShown.set(true) + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.la_lock_mode), + text = null, + confirmText = generalGetString(MR.strings.la_lock_mode_passcode), + dismissText = generalGetString(MR.strings.la_lock_mode_system), + onConfirm = { + AlertManager.shared.hideAlert() + setPasscode() + }, + onDismiss = { + AlertManager.shared.hideAlert() + initialEnableLA() + } + ) + } + + private fun initialEnableLA() { + val m = ChatModel + val appPrefs = m.controller.appPrefs + m.controller.appPrefs.laMode.set(LAMode.SYSTEM) + authenticate( + generalGetString(MR.strings.auth_enable_simplex_lock), + generalGetString(MR.strings.auth_confirm_credential), + completed = { laResult -> + when (laResult) { + LAResult.Success -> { + m.performLA.value = true + appPrefs.performLA.set(true) + laTurnedOnAlert() + } + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + is LAResult.Error -> { + m.performLA.value = false + appPrefs.performLA.set(false) + laFailedAlert() + } + is LAResult.Unavailable -> { + m.performLA.value = false + appPrefs.performLA.set(false) + m.showAdvertiseLAUnavailableAlert.value = true + } + } + } + ) + } + + private fun setPasscode() { + val chatModel = ChatModel + val appPrefs = chatModel.controller.appPrefs + ModalManager.shared.showCustomModal { close -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + submit = { + chatModel.performLA.value = true + appPrefs.performLA.set(true) + appPrefs.laMode.set(LAMode.PASSCODE) + laTurnedOnAlert() + }, + cancel = { + chatModel.performLA.value = false + appPrefs.performLA.set(false) + laPasscodeNotSetAlert() + }, + close = close + ) + } + } + } + + fun setAuthState() { + userAuthorized.value = !ChatModel.controller.appPrefs.performLA.get() + } + + fun runAuthenticate() { + val m = ChatModel + setAuthState() + if (userAuthorized.value == false) { + // To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices + CoroutineScope(Dispatchers.Default).launch { + delay(50) + withContext(Dispatchers.Main) { + authenticate( + if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) + generalGetString(MR.strings.auth_unlock) + else + generalGetString(MR.strings.la_enter_app_passcode), + if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) + generalGetString(MR.strings.auth_log_in_using_credential) + else + generalGetString(MR.strings.auth_unlock), + selfDestruct = true, + completed = { laResult -> + when (laResult) { + LAResult.Success -> + userAuthorized.value = true + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + is LAResult.Error -> { + laFailed.value = true + if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) { + laFailedAlert() + } + } + is LAResult.Unavailable -> { + userAuthorized.value = true + m.performLA.value = false + m.controller.appPrefs.performLA.set(false) + laUnavailableTurningOffAlert() + } + } + } + ) + } + } + } + } + + fun setPerformLA(on: Boolean) { + ChatModel.controller.appPrefs.laNoticeShown.set(true) + if (on) { + enableLA() + } else { + disableLA() + } + } + + private fun enableLA() { + val m = ChatModel + authenticate( + if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) + generalGetString(MR.strings.auth_enable_simplex_lock) + else + generalGetString(MR.strings.new_passcode), + if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) + generalGetString(MR.strings.auth_confirm_credential) + else + "", + completed = { laResult -> + val prefPerformLA = m.controller.appPrefs.performLA + when (laResult) { + LAResult.Success -> { + m.performLA.value = true + prefPerformLA.set(true) + laTurnedOnAlert() + } + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + is LAResult.Error -> { + m.performLA.value = false + prefPerformLA.set(false) + laFailedAlert() + } + is LAResult.Unavailable -> { + m.performLA.value = false + prefPerformLA.set(false) + laUnavailableInstructionAlert() + } + } + } + ) + } + + private fun disableLA() { + val m = ChatModel + authenticate( + if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) + generalGetString(MR.strings.auth_disable_simplex_lock) + else + generalGetString(MR.strings.la_enter_app_passcode), + if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) + generalGetString(MR.strings.auth_confirm_credential) + else + generalGetString(MR.strings.auth_disable_simplex_lock), + completed = { laResult -> + val prefPerformLA = m.controller.appPrefs.performLA + val selfDestructPref = m.controller.appPrefs.selfDestruct + when (laResult) { + LAResult.Success -> { + m.performLA.value = false + prefPerformLA.set(false) + DatabaseUtils.ksAppPassword.remove() + selfDestructPref.set(false) + DatabaseUtils.ksSelfDestructPassword.remove() + } + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + is LAResult.Error -> { + m.performLA.value = true + prefPerformLA.set(true) + laFailedAlert() + } + is LAResult.Unavailable -> { + m.performLA.value = false + prefPerformLA.set(false) + laUnavailableTurningOffAlert() + } + } + } + ) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 1b04b1e673..76604ac13a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -13,60 +13,28 @@ import androidx.compose.animation.core.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.* -import chat.simplex.app.MainActivity.Companion.enteredBackground +import chat.simplex.app.helpers.applyAppLocale import chat.simplex.app.model.* import chat.simplex.app.model.NtfManager.getUserIdFromIntent +import chat.simplex.app.platform.mainActivity import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.SplashView -import chat.simplex.app.views.call.ActiveCallView -import chat.simplex.app.views.call.IncomingCallAlertView -import chat.simplex.app.views.chat.ChatView import chat.simplex.app.views.chatlist.* -import chat.simplex.app.views.database.DatabaseErrorView import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword -import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword -import chat.simplex.app.views.localauth.SetAppPasscodeView import chat.simplex.app.views.newchat.* import chat.simplex.app.views.onboarding.* -import chat.simplex.app.views.usersettings.LAMode +import chat.simplex.app.views.usersettings.* import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.distinctUntilChanged import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { - companion object { - /** - * We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user - * clicks on new message in notification. In this case savedInstanceState will be null (this prevents restoring the values) - * See [SimplexService.onTaskRemoved] for another part of the logic which nullifies the values when app closed by the user - * */ - val userAuthorized = mutableStateOf(null) - val enteredBackground = mutableStateOf(null) - // Remember result and show it after orientation change - private val laFailed = mutableStateOf(false) - - fun clearAuthState() { - userAuthorized.value = null - enteredBackground.value = null - } - } private val vm by viewModels() - private val destroyedAfterBackPress = mutableStateOf(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - SimplexApp.context.mainActivity = WeakReference(this) + mainActivity = WeakReference(this) // testJson() val m = vm.chatModel applyAppLocale(m.controller.appPrefs.appLanguage) @@ -89,12 +57,12 @@ class MainActivity: FragmentActivity() { Surface(color = MaterialTheme.colors.background) { MainPage( m, - userAuthorized, - laFailed, - destroyedAfterBackPress, - ::runAuthenticate, - ::setPerformLA, - showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown) } + AppLock.userAuthorized, + AppLock.laFailed, + AppLock.destroyedAfterBackPress, + { AppLock.runAuthenticate() }, + { AppLock.setPerformLA(it) }, + showLANotice = { AppLock.showLANotice(m.controller.appPrefs.laNoticeShown) } ) } } @@ -111,14 +79,14 @@ class MainActivity: FragmentActivity() { override fun onResume() { super.onResume() - val enteredBackgroundVal = enteredBackground.value + val enteredBackgroundVal = AppLock.enteredBackground.value val delay = vm.chatModel.controller.appPrefs.laLockDelay.get() if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) { - if (userAuthorized.value != false) { + if (AppLock.userAuthorized.value != false) { /** [runAuthenticate] will be called in [MainPage] if needed. Making like this prevents double showing of passcode on start */ - setAuthState() + AppLock.setAuthState() } else if (!vm.chatModel.activeCallViewIsVisible.value) { - runAuthenticate() + AppLock.runAuthenticate() } } } @@ -126,17 +94,17 @@ class MainActivity: FragmentActivity() { override fun onPause() { super.onPause() /** - * When new activity is created after a click on notification, the old one receives onPause before - * recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent - * unwanted multiple auth dialogs from [runAuthenticate] - * */ - enteredBackground.value = elapsedRealtime() + * When new activity is created after a click on notification, the old one receives onPause before + * recreation but receives onStop after recreation. So using both (onPause and onStop) to prevent + * unwanted multiple auth dialogs from [runAuthenticate] + * */ + AppLock.enteredBackground.value = elapsedRealtime() } override fun onStop() { super.onStop() VideoPlayer.stopAll() - enteredBackground.value = elapsedRealtime() + AppLock.enteredBackground.value = elapsedRealtime() } override fun onBackPressed() { @@ -151,233 +119,15 @@ class MainActivity: FragmentActivity() { if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) { // When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch - clearAuthState() - laFailed.value = true - destroyedAfterBackPress.value = true + AppLock.clearAuthState() + AppLock.laFailed.value = true + AppLock.destroyedAfterBackPress.value = true } if (!onBackPressedDispatcher.hasEnabledCallbacks()) { // Drop shared content SimplexApp.context.chatModel.sharedContent.value = null } } - - private fun setAuthState() { - userAuthorized.value = !vm.chatModel.controller.appPrefs.performLA.get() - } - - private fun runAuthenticate() { - val m = vm.chatModel - setAuthState() - if (userAuthorized.value == false) { - // To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices - CoroutineScope(Dispatchers.Default).launch { - delay(50) - withContext(Dispatchers.Main) { - authenticate( - if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) - generalGetString(MR.strings.auth_unlock) - else - generalGetString(MR.strings.la_enter_app_passcode), - if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) - generalGetString(MR.strings.auth_log_in_using_credential) - else - generalGetString(MR.strings.auth_unlock), - selfDestruct = true, - completed = { laResult -> - when (laResult) { - LAResult.Success -> - userAuthorized.value = true - is LAResult.Failed -> { /* Can be called multiple times on every failure */ } - is LAResult.Error -> { - laFailed.value = true - if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) { - laFailedAlert() - } - } - is LAResult.Unavailable -> { - userAuthorized.value = true - m.performLA.value = false - m.controller.appPrefs.performLA.set(false) - laUnavailableTurningOffAlert() - } - } - } - ) - } - } - } - } - - private fun showLANotice(laNoticeShown: SharedPreference) { - Log.d(TAG, "showLANotice") - if (!laNoticeShown.get()) { - laNoticeShown.set(true) - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.la_notice_title_simplex_lock), - text = generalGetString(MR.strings.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled), - confirmText = generalGetString(MR.strings.la_notice_turn_on), - onConfirm = { - withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager - showChooseLAMode(laNoticeShown) - } - } - ) - } - } - - private fun showChooseLAMode(laNoticeShown: SharedPreference) { - Log.d(TAG, "showLANotice") - laNoticeShown.set(true) - AlertManager.shared.showAlertDialogStacked( - title = generalGetString(MR.strings.la_lock_mode), - text = null, - confirmText = generalGetString(MR.strings.la_lock_mode_passcode), - dismissText = generalGetString(MR.strings.la_lock_mode_system), - onConfirm = { - AlertManager.shared.hideAlert() - setPasscode() - }, - onDismiss = { - AlertManager.shared.hideAlert() - initialEnableLA() - } - ) - } - - private fun initialEnableLA() { - val m = vm.chatModel - val appPrefs = m.controller.appPrefs - m.controller.appPrefs.laMode.set(LAMode.SYSTEM) - authenticate( - generalGetString(MR.strings.auth_enable_simplex_lock), - generalGetString(MR.strings.auth_confirm_credential), - completed = { laResult -> - when (laResult) { - LAResult.Success -> { - m.performLA.value = true - appPrefs.performLA.set(true) - laTurnedOnAlert() - } - is LAResult.Failed -> { /* Can be called multiple times on every failure */ } - is LAResult.Error -> { - m.performLA.value = false - appPrefs.performLA.set(false) - laFailedAlert() - } - is LAResult.Unavailable -> { - m.performLA.value = false - appPrefs.performLA.set(false) - m.showAdvertiseLAUnavailableAlert.value = true - } - } - } - ) - } - - private fun setPasscode() { - val chatModel = vm.chatModel - val appPrefs = chatModel.controller.appPrefs - ModalManager.shared.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - SetAppPasscodeView( - submit = { - chatModel.performLA.value = true - appPrefs.performLA.set(true) - appPrefs.laMode.set(LAMode.PASSCODE) - laTurnedOnAlert() - }, - cancel = { - chatModel.performLA.value = false - appPrefs.performLA.set(false) - laPasscodeNotSetAlert() - }, - close = close - ) - } - } - } - - private fun setPerformLA(on: Boolean) { - vm.chatModel.controller.appPrefs.laNoticeShown.set(true) - if (on) { - enableLA() - } else { - disableLA() - } - } - - private fun enableLA() { - val m = vm.chatModel - authenticate( - if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) - generalGetString(MR.strings.auth_enable_simplex_lock) - else - generalGetString(MR.strings.new_passcode), - if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) - generalGetString(MR.strings.auth_confirm_credential) - else - "", - completed = { laResult -> - val prefPerformLA = m.controller.appPrefs.performLA - when (laResult) { - LAResult.Success -> { - m.performLA.value = true - prefPerformLA.set(true) - laTurnedOnAlert() - } - is LAResult.Failed -> { /* Can be called multiple times on every failure */ } - is LAResult.Error -> { - m.performLA.value = false - prefPerformLA.set(false) - laFailedAlert() - } - is LAResult.Unavailable -> { - m.performLA.value = false - prefPerformLA.set(false) - laUnavailableInstructionAlert() - } - } - } - ) - } - - private fun disableLA() { - val m = vm.chatModel - authenticate( - if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) - generalGetString(MR.strings.auth_disable_simplex_lock) - else - generalGetString(MR.strings.la_enter_app_passcode), - if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM) - generalGetString(MR.strings.auth_confirm_credential) - else - generalGetString(MR.strings.auth_disable_simplex_lock), - completed = { laResult -> - val prefPerformLA = m.controller.appPrefs.performLA - val selfDestructPref = m.controller.appPrefs.selfDestruct - when (laResult) { - LAResult.Success -> { - m.performLA.value = false - prefPerformLA.set(false) - ksAppPassword.remove() - selfDestructPref.set(false) - ksSelfDestructPassword.remove() - } - is LAResult.Failed -> { /* Can be called multiple times on every failure */ } - is LAResult.Error -> { - m.performLA.value = true - prefPerformLA.set(true) - laFailedAlert() - } - is LAResult.Unavailable -> { - m.performLA.value = false - prefPerformLA.set(false) - laUnavailableTurningOffAlert() - } - } - } - ) - } } class SimplexViewModel(application: Application): AndroidViewModel(application) { @@ -385,187 +135,6 @@ class SimplexViewModel(application: Application): AndroidViewModel(application) val chatModel = app.chatModel } -@Composable -fun MainPage( - chatModel: ChatModel, - userAuthorized: MutableState, - laFailed: MutableState, - destroyedAfterBackPress: MutableState, - runAuthenticate: () -> Unit, - setPerformLA: (Boolean) -> Unit, - showLANotice: () -> Unit -) { - var showChatDatabaseError by rememberSaveable { - mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null) - } - LaunchedEffect(chatModel.chatDbStatus.value) { - showChatDatabaseError = chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null - } - - var showAdvertiseLAAlert by remember { mutableStateOf(false) } - LaunchedEffect(showAdvertiseLAAlert) { - if ( - !chatModel.controller.appPrefs.laNoticeShown.get() - && showAdvertiseLAAlert - && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete - && chatModel.chats.isNotEmpty() - && chatModel.activeCallInvitation.value == null - ) { - showLANotice() - } - } - LaunchedEffect(chatModel.showAdvertiseLAUnavailableAlert.value) { - if (chatModel.showAdvertiseLAUnavailableAlert.value) { - laUnavailableInstructionAlert() - } - } - LaunchedEffect(chatModel.clearOverlays.value) { - if (chatModel.clearOverlays.value) { - ModalManager.shared.closeModals() - chatModel.clearOverlays.value = false - } - } - - @Composable - fun AuthView() { - Surface(color = MaterialTheme.colors.background) { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - laFailed.value = false - runAuthenticate() - } - ) - } - } - } - - Box { - val onboarding = chatModel.onboardingStage.value - val userCreated = chatModel.userCreated.value - var showInitializationView by remember { mutableStateOf(false) } - when { - chatModel.chatDbStatus.value == null && showInitializationView -> InitializationView() - showChatDatabaseError -> { - chatModel.chatDbStatus.value?.let { - DatabaseErrorView(chatModel.chatDbStatus, chatModel.controller.appPrefs) - } - } - onboarding == null || userCreated == null -> SplashView() - onboarding == OnboardingStage.OnboardingComplete && userCreated -> { - Box { - showAdvertiseLAAlert = true - BoxWithConstraints { - var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) } - val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) } - Box( - Modifier - .graphicsLayer { - translationX = -offset.value.dp.toPx() - } - ) { - val stopped = chatModel.chatRunning.value == false - if (chatModel.sharedContent.value == null) - ChatListView(chatModel, setPerformLA, stopped) - else - ShareListView(chatModel, stopped) - } - val scope = rememberCoroutineScope() - val onComposed: () -> Unit = { - scope.launch { - offset.animateTo( - if (chatModel.chatId.value == null) 0f else maxWidth.value, - chatListAnimationSpec() - ) - if (offset.value == 0f) { - currentChatId = null - } - } - } - LaunchedEffect(Unit) { - launch { - snapshotFlow { chatModel.chatId.value } - .distinctUntilChanged() - .collect { - if (it != null) currentChatId = it - else onComposed() - } - } - } - Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ { - currentChatId?.let { - ChatView(it, chatModel, onComposed) - } - } - } - } - } - onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true) - onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} - onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) - onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) - } - ModalManager.shared.showInView() - val unauthorized = remember { derivedStateOf { userAuthorized.value != true } } - if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) { - LaunchedEffect(Unit) { - // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request - // while the screen moves to a launcher. Detect it and prevent showing the auth - if (!(destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { - runAuthenticate() - } - } - if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) { - AuthView() - } else { - SplashView() - } - } else if (chatModel.showCallView.value) { - ActiveCallView(chatModel) - } - ModalManager.shared.showPasscodeInView() - val invitation = chatModel.activeCallInvitation.value - if (invitation != null) IncomingCallAlertView(invitation, chatModel) - AlertManager.shared.showInView() - - LaunchedEffect(Unit) { - delay(1000) - if (chatModel.chatDbStatus.value == null) { - showInitializationView = true - } - } - } - - DisposableEffectOnRotate { - // When using lock delay = 0 and screen rotates, the app will be locked which is not useful. - // Let's prolong the unlocked period to 3 sec for screen rotation to take place - if (chatModel.controller.appPrefs.laLockDelay.get() == 0) { - enteredBackground.value = elapsedRealtime() + 3000 - } - } -} - -@Composable -private fun InitializationView() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator( - Modifier - .padding(bottom = DEFAULT_PADDING) - .size(30.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 2.5.dp - ) - Text(stringResource(MR.strings.opening_database)) - } - } -} - fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) { val userId = getUserIdFromIntent(intent) when (intent?.action) { @@ -667,35 +236,6 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) { fun isMediaIntent(intent: Intent): Boolean = intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true -fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) { - Log.d(TAG, "connectIfOpenedViaUri: opened via link") - if (chatModel.currentUser.value == null) { - chatModel.appOpenUrl.value = uri - } else { - withUriAction(uri) { linkType -> - val title = when (linkType) { - ConnectionLinkType.CONTACT -> generalGetString(MR.strings.connect_via_contact_link) - ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link) - ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link) - } - AlertManager.shared.showAlertDialog( - title = title, - text = if (linkType == ConnectionLinkType.GROUP) - generalGetString(MR.strings.you_will_join_group) - else - generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link), - confirmText = generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { - withApi { - Log.d(TAG, "connectIfOpenedViaUri: connecting") - connectViaUri(chatModel, linkType, uri) - } - } - ) - } - } -} - suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) { // Still decrypting database if (chatModel.chatRunning.value == null) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 64994115e2..15720dcd09 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -6,10 +6,10 @@ import android.util.Log import androidx.lifecycle.* import androidx.work.* import chat.simplex.app.model.* +import chat.simplex.app.platform.* import chat.simplex.app.ui.theme.DefaultTheme import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage -import chat.simplex.app.views.usersettings.NotificationsMode import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString @@ -22,76 +22,9 @@ import kotlin.concurrent.thread const val TAG = "SIMPLEX" -// ghc's rts -external fun initHS() -// android-support -external fun pipeStdOutToSocket(socketName: String) : Int - -// SimpleX API -typealias ChatCtrl = Long -external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array -external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String -external fun chatRecvMsg(ctrl: ChatCtrl): String -external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String -external fun chatParseMarkdown(str: String): String -external fun chatParseServer(str: String): String -external fun chatPasswordHash(pwd: String, salt: String): String - class SimplexApp: Application(), LifecycleEventObserver { - var mainActivity: WeakReference = WeakReference(null) val chatModel: ChatModel get() = chatController.chatModel - val appPreferences: AppPreferences - get() = chatController.appPrefs - - val chatController: ChatController = ChatController - var isAppOnForeground: Boolean = false - - val defaultLocale: Locale = Locale.getDefault() - - suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) { - val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() - val dbAbsolutePathPrefix = getFilesDirectory() - val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp - val migrated: Array = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value) - val res: DBMigrationResult = kotlin.runCatching { - json.decodeFromString(migrated[0] as String) - }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } - val ctrl = if (res is DBMigrationResult.OK) { - migrated[1] as Long - } else null - chatController.ctrl = ctrl - chatModel.chatDbEncrypted.value = dbKey != "" - chatModel.chatDbStatus.value = res - if (res != DBMigrationResult.OK) { - Log.d(TAG, "Unable to migrate successfully: $res") - } else if (startChat) { - // If we migrated successfully means previous re-encryption process on database level finished successfully too - if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) - val user = chatController.apiGetActiveUser() - if (user == null) { - chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) - chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo - chatModel.currentUser.value = null - chatModel.users.clear() - } else { - val savedOnboardingStage = appPreferences.onboardingStage.get() - chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { - OnboardingStage.Step3_CreateSimpleXAddress - } else { - savedOnboardingStage - } - chatController.startChat(user) - // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { - SimplexService.showBackgroundServiceNoticeIfNeeded() - if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name) - SimplexService.start() - } - } - } - } - override fun onCreate() { super.onCreate() @@ -99,6 +32,7 @@ class SimplexApp: Application(), LifecycleEventObserver { return; } context = this + initHaskell() context.getDir("temp", MODE_PRIVATE).deleteRecursively() withBGApi { initChatController() @@ -194,75 +128,7 @@ class SimplexApp: Application(), LifecycleEventObserver { MessagesFetcherWorker.scheduleWork() } - private fun runMigrations() { - val lastMigration = chatModel.controller.appPrefs.lastMigratedVersionCode - if (lastMigration.get() < BuildConfig.VERSION_CODE) { - while (true) { - if (lastMigration.get() < 117) { - if (chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) { - chatModel.controller.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name) - } - lastMigration.set(117) - } else { - lastMigration.set(BuildConfig.VERSION_CODE) - break - } - } - } - } - companion object { lateinit var context: SimplexApp private set - - init { - val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2" - val s = Semaphore(0) - thread(name="stdout/stderr pipe") { - Log.d(TAG, "starting server") - var server: LocalServerSocket? = null - for (i in 0..100) { - try { - server = LocalServerSocket(socketName + i) - break - } catch (e: IOException) { - Log.e(TAG, e.stackTraceToString()) - } - } - if (server == null) { - throw Error("Unable to setup local server socket. Contact developers") - } - Log.d(TAG, "started server") - s.release() - val receiver = server.accept() - Log.d(TAG, "started receiver") - val logbuffer = FifoQueue(500) - if (receiver != null) { - val inStream = receiver.inputStream - val inStreamReader = InputStreamReader(inStream) - val input = BufferedReader(inStreamReader) - Log.d(TAG, "starting receiver loop") - while (true) { - val line = input.readLine() ?: break - Log.w("$TAG (stdout/stderr)", line) - logbuffer.add(line) - } - Log.w(TAG, "exited receiver loop") - } - } - - System.loadLibrary("app-lib") - - s.acquire() - pipeStdOutToSocket(socketName) - - initHS() - } - } -} - -class FifoQueue(private var capacity: Int) : LinkedList() { - override fun add(element: E): Boolean { - if(size > capacity) removeFirst() - return super.add(element) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 202471919f..135ab0b664 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -16,10 +16,8 @@ import androidx.compose.ui.unit.dp import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.work.* -import chat.simplex.app.model.ChatController -import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.* import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.NotificationsMode import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -97,7 +95,7 @@ class SimplexService: Service() { val self = this isStartingService = true withApi { - val chatController = (application as SimplexApp).chatController + val chatController = ChatController waitDbMigrationEnds(chatController) try { Log.w(TAG, "Starting foreground service") @@ -167,7 +165,7 @@ class SimplexService: Service() { // re-schedules the task when "Clear recent apps" is pressed override fun onTaskRemoved(rootIntent: Intent) { // Just to make sure that after restart of the app the user will need to re-authenticate - MainActivity.clearAuthState() + AppLock.clearAuthState() // If notification service isn't enabled or battery optimization isn't disabled, we shouldn't restart the service if (!SimplexApp.context.allowToStartServiceAfterAppExit()) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/helpers/Extensions.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/helpers/Extensions.kt new file mode 100644 index 0000000000..61a1f2e836 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/helpers/Extensions.kt @@ -0,0 +1,39 @@ +package chat.simplex.app.helpers + +import android.app.Activity +import android.content.res.Configuration +import chat.simplex.app.SimplexApp +import chat.simplex.app.model.SharedPreference +import chat.simplex.app.platform.defaultLocale +import java.util.* + +fun saveAppLocale(pref: SharedPreference, activity: Activity, languageCode: String? = null) { + // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java) + // localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return)) + // } else { + pref.set(languageCode) + if (languageCode == null) { + activity.applyLocale(defaultLocale) + } + activity.recreate() + // } +} + +fun Activity.applyAppLocale(pref: SharedPreference) { + // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + val lang = pref.get() + if (lang == null || lang == Locale.getDefault().language) return + applyLocale(Locale.forLanguageTag(lang)) + // } +} + +private fun Activity.applyLocale(locale: Locale) { + Locale.setDefault(locale) + val appConf = Configuration(SimplexApp.context.resources.configuration).apply { setLocale(locale) } + val activityConf = Configuration(resources.configuration).apply { setLocale(locale) } + @Suppress("DEPRECATION") + SimplexApp.context.resources.updateConfiguration(appConf, resources.displayMetrics) + @Suppress("DEPRECATION") + resources.updateConfiguration(activityConf, resources.displayMetrics) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt index f7ff5a80c8..2ad06648c5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -14,7 +14,6 @@ import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage import chat.simplex.app.views.usersettings.NotificationPreviewMode -import chat.simplex.app.views.usersettings.NotificationsMode import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.kt index f3b7e7a180..031a544260 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -13,6 +13,8 @@ import android.util.Log import android.view.Display import androidx.core.app.* import chat.simplex.app.* +import chat.simplex.app.platform.base64ToBitmap +import chat.simplex.app.platform.isAppOnForeground import chat.simplex.app.views.call.* import chat.simplex.app.views.chatlist.acceptContactRequest import chat.simplex.app.views.helpers.* @@ -172,9 +174,9 @@ object NtfManager { "notifyCallInvitation pre-requests: " + "keyguard locked ${keyguardManager.isKeyguardLocked}, " + "callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " + - "onForeground ${SimplexApp.context.isAppOnForeground}" + "onForeground ${isAppOnForeground}" ) - if (SimplexApp.context.isAppOnForeground) return + if (isAppOnForeground) return val contactId = invitation.contact.id Log.d(TAG, "notifyCallInvitation $contactId") val image = invitation.contact.image diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index f7d7de42b8..737344be1f 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1,6 +1,7 @@ package chat.simplex.app.model import android.content.* +import android.os.Build import android.util.Log import chat.simplex.app.views.helpers.* import androidx.compose.runtime.* @@ -8,6 +9,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource import chat.simplex.app.* +import chat.simplex.app.platform.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.newchat.ConnectViaLinkTab @@ -1379,7 +1381,7 @@ object ChatController { || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { withApi { receiveFile(r.user, file.fileId) } } - if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) { + if (cItem.showNotification && (!isAppOnForeground || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } @@ -3833,3 +3835,14 @@ sealed class ArchiveError { @Serializable @SerialName("import") class ArchiveErrorImport(val chatError: ChatError): ArchiveError() @Serializable @SerialName("importFile") class ArchiveErrorImportFile(val file: String, val chatError: ChatError): ArchiveError() } + +enum class NotificationsMode(private val requiresIgnoringBatterySinceSdk: Int) { + OFF(Int.MAX_VALUE), PERIODIC(Build.VERSION_CODES.M), SERVICE(Build.VERSION_CODES.S), /*INSTANT(Int.MAX_VALUE) - for Firebase notifications */; + + val requiresIgnoringBattery + get() = requiresIgnoringBatterySinceSdk <= Build.VERSION.SDK_INT + + companion object { + val default: NotificationsMode = SERVICE + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/AppCommon.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/AppCommon.common.kt new file mode 100644 index 0000000000..4d89009642 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/AppCommon.common.kt @@ -0,0 +1,30 @@ +package chat.simplex.app.platform + +import chat.simplex.app.BuildConfig +import chat.simplex.app.model.ChatController +import chat.simplex.app.ui.theme.DefaultTheme +import java.util.* + +class FifoQueue(private var capacity: Int) : LinkedList() { + override fun add(element: E): Boolean { + if(size > capacity) removeFirst() + return super.add(element) + } +} + +fun runMigrations() { + val lastMigration = ChatController.appPrefs.lastMigratedVersionCode + if (lastMigration.get() < BuildConfig.VERSION_CODE) { + while (true) { + if (lastMigration.get() < 117) { + if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) { + ChatController.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name) + } + lastMigration.set(117) + } else { + lastMigration.set(BuildConfig.VERSION_CODE) + break + } + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/AppCommon.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/AppCommon.kt new file mode 100644 index 0000000000..4fc20c504b --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/AppCommon.kt @@ -0,0 +1,61 @@ +package chat.simplex.app.platform + +import android.net.LocalServerSocket +import android.util.Log +import chat.simplex.app.* +import java.io.* +import java.lang.ref.WeakReference +import java.util.* +import java.util.concurrent.Semaphore +import kotlin.concurrent.thread + +var isAppOnForeground: Boolean = false + +@Suppress("ConstantLocale") +val defaultLocale: Locale = Locale.getDefault() + +var mainActivity: WeakReference = WeakReference(null) + +fun initHaskell() { + val socketName = BuildConfig.APPLICATION_ID + ".local.socket.address.listen.native.cmd2" + val s = Semaphore(0) + thread(name="stdout/stderr pipe") { + Log.d(TAG, "starting server") + var server: LocalServerSocket? = null + for (i in 0..100) { + try { + server = LocalServerSocket(socketName + i) + break + } catch (e: IOException) { + Log.e(TAG, e.stackTraceToString()) + } + } + if (server == null) { + throw Error("Unable to setup local server socket. Contact developers") + } + Log.d(TAG, "started server") + s.release() + val receiver = server.accept() + Log.d(TAG, "started receiver") + val logbuffer = FifoQueue(500) + if (receiver != null) { + val inStream = receiver.inputStream + val inStreamReader = InputStreamReader(inStream) + val input = BufferedReader(inStreamReader) + Log.d(TAG, "starting receiver loop") + while (true) { + val line = input.readLine() ?: break + Log.w("$TAG (stdout/stderr)", line) + logbuffer.add(line) + } + Log.w(TAG, "exited receiver loop") + } + } + + System.loadLibrary("app-lib") + + s.acquire() + pipeStdOutToSocket(socketName) + + initHS() +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Backend.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Backend.common.kt new file mode 100644 index 0000000000..c4e6d043c8 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Backend.common.kt @@ -0,0 +1,76 @@ +package chat.simplex.app.platform + +import android.util.Log +import chat.simplex.app.SimplexService +import chat.simplex.app.TAG +import chat.simplex.app.model.* +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.onboarding.OnboardingStage +import kotlinx.serialization.decodeFromString + +// ghc's rts +external fun initHS() +// android-support +external fun pipeStdOutToSocket(socketName: String) : Int + +// SimpleX API +typealias ChatCtrl = Long +external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array +external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String +external fun chatRecvMsg(ctrl: ChatCtrl): String +external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String +external fun chatParseMarkdown(str: String): String +external fun chatParseServer(str: String): String +external fun chatPasswordHash(pwd: String, salt: String): String + +val chatModel: ChatModel + get() = chatController.chatModel + +val appPreferences: AppPreferences + get() = chatController.appPrefs + +val chatController: ChatController = ChatController + + +suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) { + val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() + val dbAbsolutePathPrefix = getFilesDirectory() + val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp + val migrated: Array = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value) + val res: DBMigrationResult = kotlin.runCatching { + json.decodeFromString(migrated[0] as String) + }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } + val ctrl = if (res is DBMigrationResult.OK) { + migrated[1] as Long + } else null + chatController.ctrl = ctrl + chatModel.chatDbEncrypted.value = dbKey != "" + chatModel.chatDbStatus.value = res + if (res != DBMigrationResult.OK) { + Log.d(TAG, "Unable to migrate successfully: $res") + } else if (startChat) { + // If we migrated successfully means previous re-encryption process on database level finished successfully too + if (appPreferences.encryptionStartedAt.get() != null) appPreferences.encryptionStartedAt.set(null) + val user = chatController.apiGetActiveUser() + if (user == null) { + chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) + chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo + chatModel.currentUser.value = null + chatModel.users.clear() + } else { + val savedOnboardingStage = appPreferences.onboardingStage.get() + chatModel.onboardingStage.value = if (listOf(OnboardingStage.Step1_SimpleXInfo, OnboardingStage.Step2_CreateProfile).contains(savedOnboardingStage) && chatModel.users.size == 1) { + OnboardingStage.Step3_CreateSimpleXAddress + } else { + savedOnboardingStage + } + chatController.startChat(user) + // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet + if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + SimplexService.showBackgroundServiceNoticeIfNeeded() + if (appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name) + SimplexService.start() + } + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Files.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Files.common.kt new file mode 100644 index 0000000000..7d3c592ab4 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Files.common.kt @@ -0,0 +1,30 @@ +package chat.simplex.app.platform + +import chat.simplex.app.SimplexApp +import chat.simplex.app.model.CIFile +import java.io.File + +fun getFilesDirectory(): String { + return SimplexApp.context.filesDir.toString() +} + +fun getTempFilesDirectory(): String { + return "${getFilesDirectory()}/temp_files" +} + +fun getAppFilesDirectory(): String { + return "${getFilesDirectory()}/app_files" +} + +fun getAppFilePath(fileName: String): String { + return "${getAppFilesDirectory()}/$fileName" +} + +fun getLoadedFilePath(file: CIFile?): String? { + return if (file?.filePath != null && file.loaded) { + val filePath = getAppFilePath(file.filePath) + if (File(filePath).exists()) filePath else null + } else { + null + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Images.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Images.kt new file mode 100644 index 0000000000..7566046043 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Images.kt @@ -0,0 +1,106 @@ +package chat.simplex.app.platform + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.AnimatedImageDrawable +import android.net.Uri +import android.os.Build +import android.util.Base64 +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.drawable.toBitmap +import chat.simplex.app.* +import chat.simplex.app.views.helpers.errorBitmap +import chat.simplex.app.views.helpers.getFileName +import java.io.ByteArrayOutputStream +import kotlin.math.min +import kotlin.math.sqrt + +fun base64ToBitmap(base64ImageString: String): Bitmap { + val imageString = base64ImageString + .removePrefix("data:image/png;base64,") + .removePrefix("data:image/jpg;base64,") + try { + val imageBytes = Base64.decode(imageString, Base64.NO_WRAP) + return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + } catch (e: Exception) { + Log.e(TAG, "base64ToBitmap error: $e") + return errorBitmap + } +} + +fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String { + var img = image + var str = compressImageStr(img) + while (str.length > maxDataSize) { + val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) + val clippedRatio = min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = Bitmap.createScaledBitmap(img, width, height, true) + str = compressImageStr(img) + } + return str +} + +// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery +fun cropToSquare(image: Bitmap): Bitmap { + var xOffset = 0 + var yOffset = 0 + val side = min(image.height, image.width) + if (image.height < image.width) { + xOffset = (image.width - side) / 2 + } else { + yOffset = (image.height - side) / 2 + } + return Bitmap.createBitmap(image, xOffset, yOffset, side, side) +} + +private fun compressImageStr(bitmap: Bitmap): String { + val usePng = bitmap.hasAlpha() + val ext = if (usePng) "png" else "jpg" + return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP) +} + +private fun compressImageData(bitmap: Bitmap, usePng: Boolean): ByteArrayOutputStream { + val stream = ByteArrayOutputStream() + bitmap.compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream) + return stream +} + +fun resizeImageToDataSize(image: Bitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream { + var img = image + var stream = compressImageData(img, usePng) + while (stream.size() > maxDataSize) { + val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble()) + val clippedRatio = min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = Bitmap.createScaledBitmap(img, width, height, true) + stream = compressImageData(img, usePng) + } + return stream +} + +fun Bitmap.addLogo(): Bitmap = applyCanvas { + val radius = (width * 0.16f) / 2 + val paint = android.graphics.Paint() + paint.color = android.graphics.Color.WHITE + drawCircle(width / 2f, height / 2f, radius, paint) + val logo = SimplexApp.context.resources.getDrawable(R.mipmap.icon_foreground, null).toBitmap() + val logoSize = (width * 0.24).toInt() + translate((width - logoSize) / 2f, (height - logoSize) / 2f) + drawBitmap(logo, null, android.graphics.Rect(0, 0, logoSize, logoSize), null) +} + +fun isImage(uri: Uri): Boolean = + MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(uri)?.split(".")?.last())?.contains("image/") == true + + +fun isAnimImage(uri: Uri, drawable: Any?): Boolean { + val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable + val isAnimOldApi = Build.VERSION.SDK_INT < 28 && + (getFileName(uri)?.endsWith(".gif") == true || getFileName(uri)?.endsWith(".webp") == true) + return isAnimNewApi || isAnimOldApi +} \ No newline at end of file diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PlatformTextField.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PlatformTextField.kt new file mode 100644 index 0000000000..11305db50a --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/PlatformTextField.kt @@ -0,0 +1,158 @@ +package chat.simplex.app.platform + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.text.InputType +import android.util.Log +import android.view.OnReceiveContentListener +import android.view.ViewGroup +import android.view.inputmethod.* +import android.widget.EditText +import android.widget.TextView +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.core.view.inputmethod.InputConnectionCompat +import androidx.core.widget.doAfterTextChanged +import androidx.core.widget.doOnTextChanged +import chat.simplex.app.* +import chat.simplex.app.R +import chat.simplex.app.model.ChatModel +import chat.simplex.app.ui.theme.CurrentColors +import chat.simplex.app.views.chat.* +import chat.simplex.app.views.helpers.SharedContent +import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.delay +import java.lang.reflect.Field + +@Composable +fun NativeKeyboard( + composeState: MutableState, + textStyle: MutableState, + showDeleteTextButton: MutableState, + userIsObserver: Boolean, + onMessageChange: (String) -> Unit +) { + val cs = composeState.value + val textColor = MaterialTheme.colors.onBackground + val tintColor = MaterialTheme.colors.secondaryVariant + val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp) + val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() } + val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() } + val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() } + val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() } + var showKeyboard by remember { mutableStateOf(false) } + LaunchedEffect(cs.contextItem) { + if (cs.contextItem is ComposeContextItem.QuotedItem) { + delay(100) + showKeyboard = true + } else if (cs.contextItem is ComposeContextItem.EditingItem) { + // Keyboard will not show up if we try to show it too fast + delay(300) + showKeyboard = true + } + } + + AndroidView(modifier = Modifier, factory = { + val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) { + override fun setOnReceiveContentListener( + mimeTypes: Array?, + listener: OnReceiveContentListener? + ) { + super.setOnReceiveContentListener(mimeTypes, listener) + } + + override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { + val connection = super.onCreateInputConnection(editorInfo) + EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) + val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ -> + try { + inputContentInfo.requestPermission() + } catch (e: Exception) { + return@OnCommitContentListener false + } + ChatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri)) + true + } + return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) + } + } + editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + editText.maxLines = 16 + editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType + editText.setTextColor(textColor.toArgb()) + editText.textSize = textStyle.value.fontSize.value + val drawable = it.getDrawable(R.drawable.send_msg_view_background)!! + DrawableCompat.setTint(drawable, tintColor.toArgb()) + editText.background = drawable + editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom) + editText.setText(cs.message) + if (Build.VERSION.SDK_INT >= 29) { + editText.textCursorDrawable?.let { DrawableCompat.setTint(it, CurrentColors.value.colors.secondary.toArgb()) } + } else { + try { + val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes") + f.isAccessible = true + f.set(editText, R.drawable.edit_text_cursor) + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + } + } + editText.doOnTextChanged { text, _, _, _ -> + if (!composeState.value.inProgress) { + onMessageChange(text.toString()) + } else if (text.toString() != composeState.value.message) { + editText.setText(composeState.value.message) + } + } + editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") } + editText + }) { + it.setTextColor(textColor.toArgb()) + it.textSize = textStyle.value.fontSize.value + DrawableCompat.setTint(it.background, tintColor.toArgb()) + it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview + it.isFocusableInTouchMode = it.isFocusable + if (cs.message != it.text.toString()) { + it.setText(cs.message) + // Set cursor to the end of the text + it.setSelection(it.text.length) + } + if (showKeyboard) { + it.requestFocus() + val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT) + showKeyboard = false + } + showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress + } + if (composeState.value.preview is ComposePreview.VoicePreview) { + ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) + } else if (userIsObserver) { + ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) + } +} + +@Composable +private fun ComposeOverlay(textId: StringResource, textStyle: MutableState, padding: PaddingValues) { + Text( + generalGetString(textId), + Modifier.padding(padding), + color = MaterialTheme.colors.secondary, + style = textStyle.value.copy(fontStyle = FontStyle.Italic) + ) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Resources.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Resources.kt new file mode 100644 index 0000000000..c0065b3e23 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Resources.kt @@ -0,0 +1,9 @@ +package chat.simplex.app.platform + +import android.app.UiModeManager +import android.content.Context +import chat.simplex.app.SimplexApp + +// Non-@Composable implementation +fun isInNightMode() = + (SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Share.kt similarity index 76% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Share.kt rename to apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Share.kt index 10ef455a48..36559d71f4 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/Share.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.app.platform import android.Manifest import android.content.* @@ -7,14 +7,11 @@ import android.provider.MediaStore import android.util.Log import android.webkit.MimeTypeMap import android.widget.Toast -import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import chat.simplex.app.* import chat.simplex.app.model.CIFile +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.res.MR import java.io.BufferedOutputStream import java.io.File @@ -67,29 +64,6 @@ fun sendEmail(subject: String, body: CharSequence) { } } -@Composable -fun rememberSaveFileLauncher(ciFile: CIFile?): ManagedActivityResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(), - onResult = { destination -> - destination?.let { - val cxt = SimplexApp.context - val filePath = getLoadedFilePath(ciFile) - if (filePath != null) { - val contentResolver = cxt.contentResolver - contentResolver.openOutputStream(destination)?.let { stream -> - val outputStream = BufferedOutputStream(stream) - File(filePath).inputStream().use { it.copyTo(outputStream) } - outputStream.close() - Toast.makeText(cxt, generalGetString(MR.strings.file_saved), Toast.LENGTH_SHORT).show() - } - } else { - Toast.makeText(cxt, generalGetString(MR.strings.file_not_found), Toast.LENGTH_SHORT).show() - } - } - } - ) - fun imageMimeType(fileName: String): String { val lowercaseName = fileName.lowercase() return when { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/UI.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/UI.kt new file mode 100644 index 0000000000..4197cffb04 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/platform/UI.kt @@ -0,0 +1,61 @@ +package chat.simplex.app.platform + +import android.app.Activity +import android.content.Context +import android.content.pm.ActivityInfo +import android.graphics.Rect +import android.os.Build +import android.view.* +import android.view.inputmethod.InputMethodManager +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import chat.simplex.app.SimplexApp +import chat.simplex.app.views.helpers.KeyboardState + +@Composable +fun LockToCurrentOrientationUntilDispose() { + val context = LocalContext.current + DisposableEffect(Unit) { + val activity = context as Activity + val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager + val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation + activity.requestedOrientation = when (rotation) { + Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + // Unlock orientation + onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } + } +} + +@Composable +fun getKeyboardState(): State { + val keyboardState = remember { mutableStateOf(KeyboardState.Closed) } + val view = LocalView.current + DisposableEffect(view) { + val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom + keyboardState.value = if (keypadHeight > screenHeight * 0.15) { + KeyboardState.Opened + } else { + KeyboardState.Closed + } + } + view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) + + onDispose { + view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) + } + } + + return keyboardState +} + +fun hideKeyboard(view: View) = + (SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Theme.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Theme.kt index 0258ccbea0..75aa950d21 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Theme.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Theme.kt @@ -1,7 +1,5 @@ package chat.simplex.app.ui.theme -import android.app.UiModeManager -import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.* @@ -10,8 +8,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.* import androidx.compose.ui.unit.dp -import chat.simplex.app.R import chat.simplex.app.SimplexApp +import chat.simplex.app.platform.isInNightMode import chat.simplex.app.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow @@ -254,10 +252,6 @@ val SimplexColorPaletteApp = AppColors( val CurrentColors: MutableStateFlow = MutableStateFlow(ThemeManager.currentColors(isInNightMode())) -// Non-@Composable implementation -private fun isInNightMode() = - (SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES - @Composable fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.common.kt new file mode 100644 index 0000000000..e20c7dbbde --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.common.kt @@ -0,0 +1,50 @@ +package chat.simplex.app.ui.theme + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + h1 = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + ), + h2 = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 24.sp + ), + h3 = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 18.5.sp + ), + h4 = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 17.5.sp + ), + body1 = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + body2 = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 14.sp + ), + button = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + ), + caption = TextStyle( + fontFamily = Inter, + fontWeight = FontWeight.Normal, + fontSize = 18.sp + ) +) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.kt index a67a14d0c1..299156ecd0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.kt @@ -1,9 +1,6 @@ package chat.simplex.app.ui.theme -import androidx.compose.material.Typography -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.* -import androidx.compose.ui.unit.sp import chat.simplex.res.MR // https://github.com/rsms/inter @@ -15,47 +12,3 @@ val Inter: FontFamily = FontFamily( Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium), Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light) ) - -// Set of Material typography styles to start with -val Typography = Typography( - h1 = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Bold, - fontSize = 32.sp, - ), - h2 = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Normal, - fontSize = 24.sp - ), - h3 = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Normal, - fontSize = 18.5.sp - ), - h4 = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Normal, - fontSize = 17.5.sp - ), - body1 = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Normal, - fontSize = 16.sp - ), - body2 = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Normal, - fontSize = 14.sp - ), - button = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - ), - caption = TextStyle( - fontFamily = Inter, - fontWeight = FontWeight.Normal, - fontSize = 18.sp - ) -) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/TerminalView.kt index 264c243ebd..5b305d6a13 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.* import chat.simplex.app.views.helpers.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallManager.kt index b87f996f8e..ae59623482 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallManager.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallManager.kt @@ -3,7 +3,6 @@ package chat.simplex.app.views.call import android.util.Log import chat.simplex.app.TAG import chat.simplex.app.model.ChatModel -import chat.simplex.app.views.helpers.ModalManager import chat.simplex.app.views.helpers.withApi import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallView.kt index f937342f55..2310479ce7 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallView.kt @@ -34,12 +34,10 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewClientCompat import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.ProfileImage import chat.simplex.app.views.helpers.withApi -import chat.simplex.app.views.usersettings.NotificationsMode import com.google.accompanist.permissions.rememberMultiplePermissionsState import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/WebRTC.kt index cec29c94fe..fbd35d71ad 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/WebRTC.kt @@ -2,9 +2,7 @@ package chat.simplex.app.views.call import androidx.compose.runtime.Composable import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.* -import chat.simplex.app.model.Contact -import chat.simplex.app.model.User +import chat.simplex.app.model.* import chat.simplex.app.views.helpers.generalGetString import chat.simplex.res.MR import kotlinx.datetime.Instant @@ -214,7 +212,7 @@ fun parseRTCIceServers(servers: List): List? { } fun getIceServers(): List? { - val value = SimplexApp.context.chatController.appPrefs.webrtcIceServers.get() ?: return null + val value = ChatController.appPrefs.webrtcIceServers.get() ?: return null val servers: List = value.split("\n") return parseRTCIceServers(servers) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index f7e28ff365..dca2a04e52 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -29,9 +29,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import chat.simplex.app.R import chat.simplex.app.SimplexApp import chat.simplex.app.model.* +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.QRCode diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatItemInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatItemInfoView.kt index 6eebb7901d..c0a63b6dd0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatItemInfoView.kt @@ -18,8 +18,9 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.copyText +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.CurrentColors import chat.simplex.app.ui.theme.DEFAULT_PADDING import chat.simplex.app.views.chat.item.ItemAction diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 6f7b61725c..1e8e31360f 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* import androidx.core.content.FileProvider import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* import chat.simplex.app.views.chat.group.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt index 3c36a6e2af..6f8e89722a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -15,9 +15,9 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.app.R +import chat.simplex.app.platform.base64ToBitmap import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.UploadContent -import chat.simplex.app.views.helpers.base64ToBitmap import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.common.kt new file mode 100644 index 0000000000..f5c47fc13b --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.common.kt @@ -0,0 +1,759 @@ +@file:UseSerializers(UriSerializer::class) +package chat.simplex.app.views.chat + +import ComposeFileView +import ComposeVoiceView +import android.app.Activity +import android.graphics.* +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.* +import chat.simplex.app.platform.* +import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.serialization.* +import java.io.File +import java.nio.file.Files + +@Serializable +sealed class ComposePreview { + @Serializable object NoPreview: ComposePreview() + @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() + @Serializable class MediaPreview(val images: List, val content: List): ComposePreview() + @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() + @Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview() +} + +@Serializable +sealed class ComposeContextItem { + @Serializable object NoContextItem: ComposeContextItem() + @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() + @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() +} + +@Serializable +data class LiveMessage( + val chatItem: ChatItem, + val typedMsg: String, + val sentMsg: String, + val sent: Boolean +) + +@Serializable +data class ComposeState( + val message: String = "", + val liveMessage: LiveMessage? = null, + val preview: ComposePreview = ComposePreview.NoPreview, + val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, + val inProgress: Boolean = false, + val useLinkPreviews: Boolean +) { + constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( + editingItem.content.text, + liveMessage, + chatItemPreview(editingItem), + ComposeContextItem.EditingItem(editingItem), + useLinkPreviews = useLinkPreviews + ) + + val editing: Boolean + get() = + when (contextItem) { + is ComposeContextItem.EditingItem -> true + else -> false + } + val sendEnabled: () -> Boolean + get() = { + val hasContent = when (preview) { + is ComposePreview.MediaPreview -> true + is ComposePreview.VoicePreview -> true + is ComposePreview.FilePreview -> true + else -> message.isNotEmpty() || liveMessage != null + } + hasContent && !inProgress + } + val endLiveDisabled: Boolean + get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + + val linkPreviewAllowed: Boolean + get() = + when (preview) { + is ComposePreview.MediaPreview -> false + is ComposePreview.VoicePreview -> false + is ComposePreview.FilePreview -> false + else -> useLinkPreviews + } + val linkPreview: LinkPreview? + get() = + when (preview) { + is ComposePreview.CLinkPreview -> preview.linkPreview + else -> null + } + + val attachmentDisabled: Boolean + get() { + if (editing || liveMessage != null || inProgress) return true + return when (preview) { + ComposePreview.NoPreview -> false + is ComposePreview.CLinkPreview -> false + else -> true + } + } + + val empty: Boolean + get() = message.isEmpty() && preview is ComposePreview.NoPreview + + companion object { + fun saver(): Saver, *> = Saver( + save = { json.encodeToString(serializer(), it.value) }, + restore = { + mutableStateOf(json.decodeFromString(it)) + } + ) + } +} + +sealed class RecordingState { + object NotStarted: RecordingState() + class Started(val filePath: String, val progressMs: Int = 0): RecordingState() + class Finished(val filePath: String, val durationMs: Int): RecordingState() + + val filePathNullable: String? + get() = (this as? Started)?.filePath +} + +fun chatItemPreview(chatItem: ChatItem): ComposePreview { + val fileName = chatItem.file?.fileName ?: "" + return when (val mc = chatItem.content.msgContent) { + is MsgContent.MCText -> ComposePreview.NoPreview + is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) + // TODO: include correct type + is MsgContent.MCImage -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) + is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) + is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) + is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) + is MsgContent.MCUnknown, null -> ComposePreview.NoPreview + } +} + +@Composable +fun ComposeView( + chatModel: ChatModel, + chat: Chat, + composeState: MutableState, + attachmentOption: MutableState, + showChooseAttachment: () -> Unit +) { + val context = LocalContext.current + val linkUrl = rememberSaveable { mutableStateOf(null) } + val prevLinkUrl = rememberSaveable { mutableStateOf(null) } + val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } + val cancelledLinks = rememberSaveable { mutableSetOf() } + val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() + val maxFileSize = getMaxFileSize(FileProtocol.XFTP) + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val processPickedMedia = { uris: List, text: String? -> + val content = ArrayList() + val imagesPreview = ArrayList() + uris.forEach { uri -> + var bitmap: Bitmap? = null + when { + isImage(uri) -> { + // Image + val drawable = getDrawableFromUri(uri) + bitmap = if (drawable != null) getBitmapFromUri(uri) else null + if (isAnimImage(uri, drawable)) { + // It's a gif or webp + val fileSize = getFileSize(uri) + if (fileSize != null && fileSize <= maxFileSize) { + content.add(UploadContent.AnimatedImage(uri)) + } else { + bitmap = null + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) + ) + } + } else { + content.add(UploadContent.SimpleImage(uri)) + } + } + else -> { + // Video + val res = getBitmapFromVideo(uri) + bitmap = res.preview + val durationMs = res.duration + content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) + } + } + if (bitmap != null) { + imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) + } + } + if (imagesPreview.isNotEmpty()) { + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) + } + } + val processPickedFile = { uri: Uri?, text: String? -> + if (uri != null) { + val fileSize = getFileSize(uri) + if (fileSize != null && fileSize <= maxFileSize) { + val fileName = getFileName(uri) + if (fileName != null) { + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri)) + } + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) + ) + } + } + } + val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } + + AttachmentSelection(composeState, attachmentOption, processPickedFile, processPickedMedia) + + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): String? { + val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) } + val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + return link?.text + } + + fun loadLinkPreview(url: String, wait: Long? = null) { + if (pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) + withApi { + if (wait != null) delay(wait) + val lp = getLinkPreview(url) + if (lp != null && pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) + pendingLinkUrl.value = null + } else if (pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + pendingLinkUrl.value = null + } + } + } + } + + fun showLinkPreview(s: String) { + prevLinkUrl.value = linkUrl.value + linkUrl.value = parseMessage(s) + val url = linkUrl.value + if (url != null) { + if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { + pendingLinkUrl.value = url + loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) + } + } else { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + } + + fun resetLinkPreview() { + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + + fun clearState(live: Boolean = false) { + if (live) { + composeState.value = composeState.value.copy(inProgress = false) + } else { + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + resetLinkPreview() + } + recState.value = RecordingState.NotStarted + textStyle.value = smallFont + chatModel.removeLiveDummy() + } + + fun deleteUnusedFiles() { + chatModel.filesToDelete.forEach { it.delete() } + chatModel.filesToDelete.clear() + } + + suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? { + val aChatItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + file = file, + quotedItemId = quoted, + mc = mc, + live = live, + ttl = ttl + ) + if (aChatItem != null) { + chatModel.addChatItem(cInfo, aChatItem.chatItem) + return aChatItem.chatItem + } + if (file != null) removeFile(file) + return null + } + + suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? { + val cInfo = chat.chatInfo + val cs = composeState.value + var sent: ChatItem? + val msgText = text ?: cs.message + + fun sending() { + composeState.value = composeState.value.copy(inProgress = true) + } + + fun checkLinkPreview(): MsgContent { + return when (val composePreview = cs.preview) { + is ComposePreview.CLinkPreview -> { + val url = parseMessage(msgText) + val lp = composePreview.linkPreview + if (lp != null && url == lp.uri) { + MsgContent.MCLink(msgText, preview = lp) + } else { + MsgContent.MCText(msgText) + } + } + else -> MsgContent.MCText(msgText) + } + } + + fun updateMsgContent(msgContent: MsgContent): MsgContent { + return when (msgContent) { + is MsgContent.MCText -> checkLinkPreview() + is MsgContent.MCLink -> checkLinkPreview() + is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image) + is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) + is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) + is MsgContent.MCFile -> MsgContent.MCFile(msgText) + is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) + } + } + + suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? { + val oldMsgContent = ei.content.msgContent + if (oldMsgContent != null) { + val updatedItem = chatModel.controller.apiUpdateChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = ei.meta.itemId, + mc = updateMsgContent(oldMsgContent), + live = live + ) + if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + return updatedItem?.chatItem + } + return null + } + + val liveMessage = cs.liveMessage + if (!live) { + if (liveMessage != null) composeState.value = cs.copy(liveMessage = null) + sending() + } + + if (cs.contextItem is ComposeContextItem.EditingItem) { + val ei = cs.contextItem.chatItem + sent = updateMessage(ei, cInfo, live) + } else if (liveMessage != null && liveMessage.sent) { + sent = updateMessage(liveMessage.chatItem, cInfo, live) + } else { + val msgs: ArrayList = ArrayList() + val files: ArrayList = ArrayList() + when (val preview = cs.preview) { + ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) + is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) + is ComposePreview.MediaPreview -> { + preview.content.forEachIndexed { index, it -> + val file = when (it) { + is UploadContent.SimpleImage -> saveImage(it.uri) + is UploadContent.AnimatedImage -> saveAnimImage(it.uri) + is UploadContent.Video -> saveFileFromUri(it.uri) + } + if (file != null) { + files.add(file) + if (it is UploadContent.Video) { + msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration)) + } else { + msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index])) + } + } + } + } + is ComposePreview.VoicePreview -> { + val tmpFile = File(preview.voice) + AudioPlayer.stop(tmpFile.absolutePath) + val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderNative.extension, ""))) + withContext(Dispatchers.IO) { + Files.move(tmpFile.toPath(), actualFile.toPath()) + } + files.add(actualFile.name) + deleteUnusedFiles() + msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) + } + is ComposePreview.FilePreview -> { + val file = saveFileFromUri(preview.uri) + if (file != null) { + files.add((file)) + msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) + } + } + } + val quotedItemId: Long? = when (cs.contextItem) { + is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id + else -> null + } + sent = null + msgs.forEachIndexed { index, content -> + if (index > 0) delay(100) + sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), + live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, + ttl = ttl + ) + } + if (sent == null && + (cs.preview is ComposePreview.MediaPreview || + cs.preview is ComposePreview.FilePreview || + cs.preview is ComposePreview.VoicePreview) + ) { + sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + } + } + clearState(live) + return sent + } + + fun sendMessage(ttl: Int?) { + withBGApi { + sendMessageAsync(null, false, ttl) + } + } + + fun onMessageChange(s: String) { + composeState.value = composeState.value.copy(message = s) + if (isShortEmoji(s)) { + textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + } else { + textStyle.value = smallFont + if (composeState.value.linkPreviewAllowed) { + if (s.isNotEmpty()) showLinkPreview(s) + else resetLinkPreview() + } + } + } + + fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) { + val file = File(filePath) + chatModel.filesToDelete.add(file) + composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished)) + } + + fun allowVoiceToContact() { + val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return + withApi { + chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice) + } + } + + fun cancelLinkPreview() { + val uri = composeState.value.linkPreview?.uri + if (uri != null) { + cancelledLinks.add(uri) + } + pendingLinkUrl.value = null + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + + fun cancelImages() { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + + fun cancelVoice() { + val filePath = recState.value.filePathNullable + recState.value = RecordingState.NotStarted + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + withBGApi { + RecorderNative.stopRecording?.invoke() + AudioPlayer.stop(filePath) + filePath?.let { File(it).delete() } + } + } + + fun cancelFile() { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + + fun truncateToWords(s: String): String { + var acc = "" + val word = StringBuilder() + for (c in s) { + if (c.isLetter() || c.isDigit()) { + word.append(c) + } else { + acc = acc + word.toString() + c + word.clear() + } + } + return acc + } + + suspend fun sendLiveMessage() { + val cs = composeState.value + val typedMsg = cs.message + if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { + val ci = sendMessageAsync(typedMsg, live = true, ttl = null) + if (ci != null) { + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) + } + } else if (cs.liveMessage == null) { + val cItem = chatModel.addLiveDummy(chat.chatInfo) + composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false)) + } + } + + fun liveMessageToSend(lm: LiveMessage, t: String): String? { + val s = if (t != lm.typedMsg) truncateToWords(t) else t + return if (s != lm.sentMsg) s else null + } + + suspend fun updateLiveMessage() { + val typedMsg = composeState.value.message + val liveMessage = composeState.value.liveMessage + if (liveMessage != null) { + val sentMsg = liveMessageToSend(liveMessage, typedMsg) + if (sentMsg != null) { + val ci = sendMessageAsync(sentMsg, live = true, ttl = null) + if (ci != null) { + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) + } + } else if (liveMessage.typedMsg != typedMsg) { + composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) + } + } + } + + @Composable + fun previewView() { + when (val preview = composeState.value.preview) { + ComposePreview.NoPreview -> {} + is ComposePreview.CLinkPreview -> ComposeLinkView( + preview.linkPreview, + ::cancelLinkPreview, + cancelEnabled = !composeState.value.inProgress + ) + is ComposePreview.MediaPreview -> ComposeImageView( + preview, + ::cancelImages, + cancelEnabled = !composeState.value.editing && !composeState.value.inProgress + ) + is ComposePreview.VoicePreview -> ComposeVoiceView( + preview.voice, + preview.durationMs, + preview.finished, + cancelEnabled = !composeState.value.editing && !composeState.value.inProgress, + ::cancelVoice + ) + is ComposePreview.FilePreview -> ComposeFileView( + preview.fileName, + ::cancelFile, + cancelEnabled = !composeState.value.editing && !composeState.value.inProgress + ) + } + } + + @Composable + fun contextItemView() { + when (val contextItem = composeState.value.contextItem) { + ComposeContextItem.NoContextItem -> {} + is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_reply)) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } + is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) { + clearState() + } + } + } + + // In case a user sent something, state is in progress, the user rotates a screen to different orientation. + // Without clearing the state the user will be unable to send anything until re-enters ChatView + LaunchedEffect(Unit) { + if (composeState.value.inProgress) { + clearState() + } + } + + LaunchedEffect(chatModel.sharedContent.value) { + // Important. If it's null, don't do anything, chat is not closed yet but will be after a moment + if (chatModel.chatId.value == null) return@LaunchedEffect + + when (val shared = chatModel.sharedContent.value) { + is SharedContent.Text -> onMessageChange(shared.text) + is SharedContent.Media -> processPickedMedia(shared.uris, shared.text) + is SharedContent.File -> processPickedFile(shared.uri, shared.text) + null -> {} + } + chatModel.sharedContent.value = null + } + + val userCanSend = rememberUpdatedState(chat.userCanSend) + val userIsObserver = rememberUpdatedState(chat.userIsObserver) + + Column { + if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { + contextItemView() + when { + composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {} + composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} + else -> previewView() + } + } else { + Box { + Box(Modifier.align(Alignment.TopStart).padding(bottom = 69.dp)) { + contextItemView() + } + Box(Modifier.align(Alignment.BottomStart)) { + previewView() + } + } + } + Row( + modifier = Modifier.padding(end = 8.dp), + verticalAlignment = Alignment.Bottom, + ) { + val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on + val attachmentClicked = if (isGroupAndProhibitedFiles) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.files_and_media_prohibited), + text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + ) + } + } else { + showChooseAttachment + } + IconButton(attachmentClicked, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) { + Icon( + painterResource(MR.images.ic_attach_file_filled_500), + contentDescription = stringResource(MR.strings.attach), + tint = if (!composeState.value.attachmentDisabled && userCanSend.value && !isGroupAndProhibitedFiles) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + ) + } + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } + val needToAllowVoiceToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + LaunchedEffect(Unit) { + snapshotFlow { recState.value } + .distinctUntilChanged() + .collect { + when(it) { + is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) + is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true) + is RecordingState.NotStarted -> {} + } + } + } + + fun clearCurrentDraft() { + if (chatModel.draftChatId.value == chat.id) { + chatModel.draft.value = null + chatModel.draftChatId.value = null + } + } + + LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) { + if (!chat.userCanSend) { + clearCurrentDraft() + clearState() + } + } + + val activity = LocalContext.current as Activity + DisposableEffect(Unit) { + val orientation = activity.resources.configuration.orientation + onDispose { + if (orientation == activity.resources.configuration.orientation) { + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) + resetLinkPreview() + clearCurrentDraft() + deleteUnusedFiles() + } else if (composeState.value.inProgress) { + clearCurrentDraft() + } else if (!composeState.value.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } else { + clearCurrentDraft() + deleteUnusedFiles() + } + chatModel.removeLiveDummy() + } + } + } + + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + SendMsgView( + composeState, + showVoiceRecordIcon = true, + recState, + chat.chatInfo is ChatInfo.Direct, + liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, + needToAllowVoiceToContact, + allowedVoiceByPrefs, + allowVoiceToContact = ::allowVoiceToContact, + userIsObserver = userIsObserver.value, + userCanSend = userCanSend.value, + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + sendMessage = { ttl -> + sendMessage(ttl) + resetLinkPreview() + }, + sendLiveMessage = ::sendLiveMessage, + updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveDummy() + }, + onMessageChange = ::onMessageChange, + textStyle = textStyle + ) + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index f0569c6661..a8223582ae 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,186 +1,26 @@ -@file:UseSerializers(UriSerializer::class) package chat.simplex.app.views.chat -import ComposeFileView -import ComposeVoiceView import android.Manifest -import android.app.Activity -import android.content.* +import android.content.ActivityNotFoundException import android.content.pm.PackageManager -import android.graphics.* -import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.Bitmap import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import android.webkit.MimeTypeMap import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContract -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import chat.simplex.app.* -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.views.chat.item.* +import chat.simplex.app.SimplexApp +import chat.simplex.app.platform.resizeImageToStrSize import chat.simplex.app.views.helpers.* import chat.simplex.res.MR -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.serialization.* -import java.io.File -import java.nio.file.Files - -@Serializable -sealed class ComposePreview { - @Serializable object NoPreview: ComposePreview() - @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() - @Serializable class MediaPreview(val images: List, val content: List): ComposePreview() - @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() - @Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview() -} - -@Serializable -sealed class ComposeContextItem { - @Serializable object NoContextItem: ComposeContextItem() - @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() - @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() -} - -@Serializable -data class LiveMessage( - val chatItem: ChatItem, - val typedMsg: String, - val sentMsg: String, - val sent: Boolean -) - -@Serializable -data class ComposeState( - val message: String = "", - val liveMessage: LiveMessage? = null, - val preview: ComposePreview = ComposePreview.NoPreview, - val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, - val inProgress: Boolean = false, - val useLinkPreviews: Boolean -) { - constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this( - editingItem.content.text, - liveMessage, - chatItemPreview(editingItem), - ComposeContextItem.EditingItem(editingItem), - useLinkPreviews = useLinkPreviews - ) - - val editing: Boolean - get() = - when (contextItem) { - is ComposeContextItem.EditingItem -> true - else -> false - } - val sendEnabled: () -> Boolean - get() = { - val hasContent = when (preview) { - is ComposePreview.MediaPreview -> true - is ComposePreview.VoicePreview -> true - is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || liveMessage != null - } - hasContent && !inProgress - } - val endLiveDisabled: Boolean - get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem - - val linkPreviewAllowed: Boolean - get() = - when (preview) { - is ComposePreview.MediaPreview -> false - is ComposePreview.VoicePreview -> false - is ComposePreview.FilePreview -> false - else -> useLinkPreviews - } - val linkPreview: LinkPreview? - get() = - when (preview) { - is ComposePreview.CLinkPreview -> preview.linkPreview - else -> null - } - - val attachmentDisabled: Boolean - get() { - if (editing || liveMessage != null || inProgress) return true - return when (preview) { - ComposePreview.NoPreview -> false - is ComposePreview.CLinkPreview -> false - else -> true - } - } - - val empty: Boolean - get() = message.isEmpty() && preview is ComposePreview.NoPreview - - companion object { - fun saver(): Saver, *> = Saver( - save = { json.encodeToString(serializer(), it.value) }, - restore = { - mutableStateOf(json.decodeFromString(it)) - } - ) - } -} - -sealed class RecordingState { - object NotStarted: RecordingState() - class Started(val filePath: String, val progressMs: Int = 0): RecordingState() - class Finished(val filePath: String, val durationMs: Int): RecordingState() - - val filePathNullable: String? - get() = (this as? Started)?.filePath -} - -fun chatItemPreview(chatItem: ChatItem): ComposePreview { - val fileName = chatItem.file?.fileName ?: "" - return when (val mc = chatItem.content.msgContent) { - is MsgContent.MCText -> ComposePreview.NoPreview - is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) - // TODO: include correct type - is MsgContent.MCImage -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) - is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) - is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) - is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) - is MsgContent.MCUnknown, null -> ComposePreview.NoPreview - } -} @Composable -fun ComposeView( - chatModel: ChatModel, - chat: Chat, +fun AttachmentSelection( composeState: MutableState, attachmentOption: MutableState, - showChooseAttachment: () -> Unit + processPickedFile: (Uri?, String?) -> Unit, + processPickedMedia: (List, String?) -> Unit ) { - val context = LocalContext.current - val linkUrl = rememberSaveable { mutableStateOf(null) } - val prevLinkUrl = rememberSaveable { mutableStateOf(null) } - val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } - val cancelledLinks = rememberSaveable { mutableSetOf() } - val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() - val maxFileSize = getMaxFileSize(FileProtocol.XFTP) - val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - val textStyle = remember { mutableStateOf(smallFont) } val cameraLauncher = rememberCameraLauncher { uri: Uri? -> if (uri != null) { val bitmap: Bitmap? = getBitmapFromUri(uri) @@ -194,69 +34,7 @@ fun ComposeView( if (isGranted) { cameraLauncher.launchWithFallback() } else { - Toast.makeText(context, generalGetString(MR.strings.toast_permission_denied), Toast.LENGTH_SHORT).show() - } - } - val processPickedMedia = { uris: List, text: String? -> - val content = ArrayList() - val imagesPreview = ArrayList() - uris.forEach { uri -> - var bitmap: Bitmap? = null - val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(uri)?.split(".")?.last())?.contains("image/") == true - when { - isImage -> { - // Image - val drawable = getDrawableFromUri(uri) - bitmap = if (drawable != null) getBitmapFromUri(uri) else null - val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable - val isAnimOldApi = Build.VERSION.SDK_INT < 28 && - (getFileName(uri)?.endsWith(".gif") == true || getFileName(uri)?.endsWith(".webp") == true) - if (isAnimNewApi || isAnimOldApi) { - // It's a gif or webp - val fileSize = getFileSize(uri) - if (fileSize != null && fileSize <= maxFileSize) { - content.add(UploadContent.AnimatedImage(uri)) - } else { - bitmap = null - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) - ) - } - } else { - content.add(UploadContent.SimpleImage(uri)) - } - } - else -> { - // Video - val res = getBitmapFromVideo(uri) - bitmap = res.preview - val durationMs = res.duration - content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) - } - } - if (bitmap != null) { - imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) - } - } - if (imagesPreview.isNotEmpty()) { - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) - } - } - val processPickedFile = { uri: Uri?, text: String? -> - if (uri != null) { - val fileSize = getFileSize(uri) - if (fileSize != null && fileSize <= maxFileSize) { - val fileName = getFileName(uri) - if (fileName != null) { - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri)) - } - } else { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize)) - ) - } + Toast.makeText(SimplexApp.context, generalGetString(MR.strings.toast_permission_denied), Toast.LENGTH_SHORT).show() } } val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) } @@ -265,13 +43,11 @@ fun ComposeView( val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) } val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) } - val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } - LaunchedEffect(attachmentOption.value) { when (attachmentOption.value) { AttachmentOption.CameraPhoto -> { when (PackageManager.PERMISSION_GRANTED) { - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { + ContextCompat.checkSelfPermission(SimplexApp.context, Manifest.permission.CAMERA) -> { cameraLauncher.launchWithFallback() } else -> { @@ -303,595 +79,4 @@ fun ComposeView( else -> {} } } - - fun isSimplexLink(link: String): Boolean = - link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) - - fun parseMessage(msg: String): String? { - val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) } - val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } - return link?.text - } - - fun loadLinkPreview(url: String, wait: Long? = null) { - if (pendingLinkUrl.value == url) { - composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) - withApi { - if (wait != null) delay(wait) - val lp = getLinkPreview(url) - if (lp != null && pendingLinkUrl.value == url) { - composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) - pendingLinkUrl.value = null - } else if (pendingLinkUrl.value == url) { - composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - pendingLinkUrl.value = null - } - } - } - } - - fun showLinkPreview(s: String) { - prevLinkUrl.value = linkUrl.value - linkUrl.value = parseMessage(s) - val url = linkUrl.value - if (url != null) { - if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { - pendingLinkUrl.value = url - loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) - } - } else { - composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - } - } - - fun resetLinkPreview() { - linkUrl.value = null - prevLinkUrl.value = null - pendingLinkUrl.value = null - cancelledLinks.clear() - } - - fun clearState(live: Boolean = false) { - if (live) { - composeState.value = composeState.value.copy(inProgress = false) - } else { - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) - resetLinkPreview() - } - recState.value = RecordingState.NotStarted - textStyle.value = smallFont - chatModel.removeLiveDummy() - } - - fun deleteUnusedFiles() { - chatModel.filesToDelete.forEach { it.delete() } - chatModel.filesToDelete.clear() - } - - suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false, ttl: Int?): ChatItem? { - val aChatItem = chatModel.controller.apiSendMessage( - type = cInfo.chatType, - id = cInfo.apiId, - file = file, - quotedItemId = quoted, - mc = mc, - live = live, - ttl = ttl - ) - if (aChatItem != null) { - chatModel.addChatItem(cInfo, aChatItem.chatItem) - return aChatItem.chatItem - } - if (file != null) removeFile(file) - return null - } - - - - suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? { - val cInfo = chat.chatInfo - val cs = composeState.value - var sent: ChatItem? - val msgText = text ?: cs.message - - fun sending() { - composeState.value = composeState.value.copy(inProgress = true) - } - - fun checkLinkPreview(): MsgContent { - return when (val composePreview = cs.preview) { - is ComposePreview.CLinkPreview -> { - val url = parseMessage(msgText) - val lp = composePreview.linkPreview - if (lp != null && url == lp.uri) { - MsgContent.MCLink(msgText, preview = lp) - } else { - MsgContent.MCText(msgText) - } - } - else -> MsgContent.MCText(msgText) - } - } - - fun updateMsgContent(msgContent: MsgContent): MsgContent { - return when (msgContent) { - is MsgContent.MCText -> checkLinkPreview() - is MsgContent.MCLink -> checkLinkPreview() - is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image) - is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) - is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) - is MsgContent.MCFile -> MsgContent.MCFile(msgText) - is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) - } - } - - suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? { - val oldMsgContent = ei.content.msgContent - if (oldMsgContent != null) { - val updatedItem = chatModel.controller.apiUpdateChatItem( - type = cInfo.chatType, - id = cInfo.apiId, - itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent), - live = live - ) - if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) - return updatedItem?.chatItem - } - return null - } - - val liveMessage = cs.liveMessage - if (!live) { - if (liveMessage != null) composeState.value = cs.copy(liveMessage = null) - sending() - } - - if (cs.contextItem is ComposeContextItem.EditingItem) { - val ei = cs.contextItem.chatItem - sent = updateMessage(ei, cInfo, live) - } else if (liveMessage != null && liveMessage.sent) { - sent = updateMessage(liveMessage.chatItem, cInfo, live) - } else { - val msgs: ArrayList = ArrayList() - val files: ArrayList = ArrayList() - when (val preview = cs.preview) { - ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) - is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) - is ComposePreview.MediaPreview -> { - preview.content.forEachIndexed { index, it -> - val file = when (it) { - is UploadContent.SimpleImage -> saveImage(it.uri) - is UploadContent.AnimatedImage -> saveAnimImage(it.uri) - is UploadContent.Video -> saveFileFromUri(it.uri) - } - if (file != null) { - files.add(file) - if (it is UploadContent.Video) { - msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration)) - } else { - msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index])) - } - } - } - } - is ComposePreview.VoicePreview -> { - val tmpFile = File(preview.voice) - AudioPlayer.stop(tmpFile.absolutePath) - val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderNative.extension, ""))) - withContext(Dispatchers.IO) { - Files.move(tmpFile.toPath(), actualFile.toPath()) - } - files.add(actualFile.name) - deleteUnusedFiles() - msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) - } - is ComposePreview.FilePreview -> { - val file = saveFileFromUri(preview.uri) - if (file != null) { - files.add((file)) - msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) - } - } - } - val quotedItemId: Long? = when (cs.contextItem) { - is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id - else -> null - } - sent = null - msgs.forEachIndexed { index, content -> - if (index > 0) delay(100) - sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index), - live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, - ttl = ttl - ) - } - if (sent == null && - (cs.preview is ComposePreview.MediaPreview || - cs.preview is ComposePreview.FilePreview || - cs.preview is ComposePreview.VoicePreview) - ) { - sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) - } - } - clearState(live) - return sent - } - - fun sendMessage(ttl: Int?) { - withBGApi { - sendMessageAsync(null, false, ttl) - } - } - - fun onMessageChange(s: String) { - composeState.value = composeState.value.copy(message = s) - if (isShortEmoji(s)) { - textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont - } else { - textStyle.value = smallFont - if (composeState.value.linkPreviewAllowed) { - if (s.isNotEmpty()) showLinkPreview(s) - else resetLinkPreview() - } - } - } - - fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) { - val file = File(filePath) - chatModel.filesToDelete.add(file) - composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished)) - } - - fun allowVoiceToContact() { - val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return - withApi { - chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice) - } - } - - fun cancelLinkPreview() { - val uri = composeState.value.linkPreview?.uri - if (uri != null) { - cancelledLinks.add(uri) - } - pendingLinkUrl.value = null - composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - } - - fun cancelImages() { - composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - } - - fun cancelVoice() { - val filePath = recState.value.filePathNullable - recState.value = RecordingState.NotStarted - composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - withBGApi { - RecorderNative.stopRecording?.invoke() - AudioPlayer.stop(filePath) - filePath?.let { File(it).delete() } - } - } - - fun cancelFile() { - composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - } - - fun truncateToWords(s: String): String { - var acc = "" - val word = StringBuilder() - for (c in s) { - if (c.isLetter() || c.isDigit()) { - word.append(c) - } else { - acc = acc + word.toString() + c - word.clear() - } - } - return acc - } - - suspend fun sendLiveMessage() { - val cs = composeState.value - val typedMsg = cs.message - if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { - val ci = sendMessageAsync(typedMsg, live = true, ttl = null) - if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) - } - } else if (cs.liveMessage == null) { - val cItem = chatModel.addLiveDummy(chat.chatInfo) - composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false)) - } - } - - fun liveMessageToSend(lm: LiveMessage, t: String): String? { - val s = if (t != lm.typedMsg) truncateToWords(t) else t - return if (s != lm.sentMsg) s else null - } - - suspend fun updateLiveMessage() { - val typedMsg = composeState.value.message - val liveMessage = composeState.value.liveMessage - if (liveMessage != null) { - val sentMsg = liveMessageToSend(liveMessage, typedMsg) - if (sentMsg != null) { - val ci = sendMessageAsync(sentMsg, live = true, ttl = null) - if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) - } - } else if (liveMessage.typedMsg != typedMsg) { - composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) - } - } - } - - @Composable - fun previewView() { - when (val preview = composeState.value.preview) { - ComposePreview.NoPreview -> {} - is ComposePreview.CLinkPreview -> ComposeLinkView( - preview.linkPreview, - ::cancelLinkPreview, - cancelEnabled = !composeState.value.inProgress - ) - is ComposePreview.MediaPreview -> ComposeImageView( - preview, - ::cancelImages, - cancelEnabled = !composeState.value.editing && !composeState.value.inProgress - ) - is ComposePreview.VoicePreview -> ComposeVoiceView( - preview.voice, - preview.durationMs, - preview.finished, - cancelEnabled = !composeState.value.editing && !composeState.value.inProgress, - ::cancelVoice - ) - is ComposePreview.FilePreview -> ComposeFileView( - preview.fileName, - ::cancelFile, - cancelEnabled = !composeState.value.editing && !composeState.value.inProgress - ) - } - } - - @Composable - fun contextItemView() { - when (val contextItem = composeState.value.contextItem) { - ComposeContextItem.NoContextItem -> {} - is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_reply)) { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) - } - is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) { - clearState() - } - } - } - - // In case a user sent something, state is in progress, the user rotates a screen to different orientation. - // Without clearing the state the user will be unable to send anything until re-enters ChatView - LaunchedEffect(Unit) { - if (composeState.value.inProgress) { - clearState() - } - } - - LaunchedEffect(chatModel.sharedContent.value) { - // Important. If it's null, don't do anything, chat is not closed yet but will be after a moment - if (chatModel.chatId.value == null) return@LaunchedEffect - - when (val shared = chatModel.sharedContent.value) { - is SharedContent.Text -> onMessageChange(shared.text) - is SharedContent.Media -> processPickedMedia(shared.uris, shared.text) - is SharedContent.File -> processPickedFile(shared.uri, shared.text) - null -> {} - } - chatModel.sharedContent.value = null - } - - val userCanSend = rememberUpdatedState(chat.userCanSend) - val userIsObserver = rememberUpdatedState(chat.userIsObserver) - - Column { - if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { - contextItemView() - when { - composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {} - composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} - else -> previewView() - } - } else { - Box { - Box(Modifier.align(Alignment.TopStart).padding(bottom = 69.dp)) { - contextItemView() - } - Box(Modifier.align(Alignment.BottomStart)) { - previewView() - } - } - } - Row( - modifier = Modifier.padding(end = 8.dp), - verticalAlignment = Alignment.Bottom, - ) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on - val attachmentClicked = if (isGroupAndProhibitedFiles) { - { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.files_and_media_prohibited), - text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) - ) - } - } else { - showChooseAttachment - } - IconButton(attachmentClicked, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) { - Icon( - painterResource(MR.images.ic_attach_file_filled_500), - contentDescription = stringResource(MR.strings.attach), - tint = if (!composeState.value.attachmentDisabled && userCanSend.value && !isGroupAndProhibitedFiles) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - ) - } - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } - LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { - // Voice was disabled right when this user records it, just cancel it - cancelVoice() - } - } - val needToAllowVoiceToContact = remember(chat.chatInfo) { - chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { - ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && - contactPreference.allow == FeatureAllowed.YES - } - } - LaunchedEffect(Unit) { - snapshotFlow { recState.value } - .distinctUntilChanged() - .collect { - when(it) { - is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true) - is RecordingState.NotStarted -> {} - } - } - } - - fun clearCurrentDraft() { - if (chatModel.draftChatId.value == chat.id) { - chatModel.draft.value = null - chatModel.draftChatId.value = null - } - } - - LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) { - if (!chat.userCanSend) { - clearCurrentDraft() - clearState() - } - } - - val activity = LocalContext.current as Activity - DisposableEffect(Unit) { - val orientation = activity.resources.configuration.orientation - onDispose { - if (orientation == activity.resources.configuration.orientation) { - val cs = composeState.value - if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) - resetLinkPreview() - clearCurrentDraft() - deleteUnusedFiles() - } else if (composeState.value.inProgress) { - clearCurrentDraft() - } else if (!composeState.value.empty) { - if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { - composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) - } - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = chat.id - } else { - clearCurrentDraft() - deleteUnusedFiles() - } - chatModel.removeLiveDummy() - } - } - } - - val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } - SendMsgView( - composeState, - showVoiceRecordIcon = true, - recState, - chat.chatInfo is ChatInfo.Direct, - liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, - needToAllowVoiceToContact, - allowedVoiceByPrefs, - allowVoiceToContact = ::allowVoiceToContact, - userIsObserver = userIsObserver.value, - userCanSend = userCanSend.value, - timedMessageAllowed = timedMessageAllowed, - customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - sendMessage = { ttl -> - sendMessage(ttl) - resetLinkPreview() - }, - sendLiveMessage = ::sendLiveMessage, - updateLiveMessage = ::updateLiveMessage, - cancelLiveMessage = { - composeState.value = composeState.value.copy(liveMessage = null) - chatModel.removeLiveDummy() - }, - onMessageChange = ::onMessageChange, - textStyle = textStyle - ) - } - } -} - -class PickFromGallery: ActivityResultContract() { - override fun createIntent(context: Context, input: Int) = - Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply { - type = "image/*" - } - - override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data -} - -class PickMultipleImagesFromGallery: ActivityResultContract>() { - override fun createIntent(context: Context, input: Int) = - Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply { - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - type = "image/*" - } - - override fun parseResult(resultCode: Int, intent: Intent?): List = - if (intent?.data != null) - listOf(intent.data!!) - else if (intent?.clipData != null) - with(intent.clipData!!) { - val uris = ArrayList() - for (i in 0 until kotlin.math.min(itemCount, 10)) { - val uri = getItemAt(i).uri - if (uri != null) uris.add(uri) - } - if (itemCount > 10) { - AlertManager.shared.showAlertMsg(MR.strings.images_limit_title, MR.strings.images_limit_desc) - } - uris - } - else - emptyList() -} - - -class PickMultipleVideosFromGallery: ActivityResultContract>() { - override fun createIntent(context: Context, input: Int) = - Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply { - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - type = "video/*" - } - - override fun parseResult(resultCode: Int, intent: Intent?): List = - if (intent?.data != null) - listOf(intent.data!!) - else if (intent?.clipData != null) - with(intent.clipData!!) { - val uris = ArrayList() - for (i in 0 until kotlin.math.min(itemCount, 10)) { - val uri = getItemAt(i).uri - if (uri != null) uris.add(uri) - } - if (itemCount > 10) { - AlertManager.shared.showAlertMsg(MR.strings.videos_limit_title, MR.strings.videos_limit_desc) - } - uris - } - else - emptyList() } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.common.kt new file mode 100644 index 0000000000..a8f88d7ed8 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.common.kt @@ -0,0 +1,41 @@ +package chat.simplex.app.views.chat + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.QRCodeScanner +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = DEFAULT_PADDING) + ) { + AppBarTitle(stringResource(MR.strings.scan_code), false) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(bottom = DEFAULT_PADDING) + ) { + QRCodeScanner { text -> + verifyCode(text) { + if (it) { + close() + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.incorrect_code) + ) + } + } + } + } + Text(stringResource(MR.strings.scan_code_from_contacts_app)) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt index 69304f8398..d7ef0895ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt @@ -1,18 +1,9 @@ package chat.simplex.app.views.chat import android.Manifest -import androidx.compose.foundation.layout.* -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.R -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCodeScanner import com.google.accompanist.permissions.rememberPermissionState -import chat.simplex.res.MR @Composable fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { @@ -22,33 +13,3 @@ fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () } ScanCodeLayout(verifyCode, close) } - -@Composable -private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - Column( - Modifier - .fillMaxSize() - .padding(horizontal = DEFAULT_PADDING) - ) { - AppBarTitle(stringResource(MR.strings.scan_code), false) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 1F) - .padding(bottom = DEFAULT_PADDING) - ) { - QRCodeScanner { text -> - verifyCode(text) { - if (it) { - close() - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.incorrect_code) - ) - } - } - } - } - Text(stringResource(MR.strings.scan_code_from_contacts_app)) - } -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.common.kt new file mode 100644 index 0000000000..e644cfde1c --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.common.kt @@ -0,0 +1,653 @@ +package chat.simplex.app.views.chat + +import android.content.res.Configuration +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.* +import androidx.compose.material.* +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.* +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.Dialog +import chat.simplex.app.model.* +import chat.simplex.app.platform.LockToCurrentOrientationUntilDispose +import chat.simplex.app.platform.NativeKeyboard +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.item.ItemAction +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.* + +@Composable +fun SendMsgView( + composeState: MutableState, + showVoiceRecordIcon: Boolean, + recState: MutableState, + isDirectChat: Boolean, + liveMessageAlertShown: SharedPreference, + needToAllowVoiceToContact: Boolean, + allowedVoiceByPrefs: Boolean, + userIsObserver: Boolean, + userCanSend: Boolean, + allowVoiceToContact: () -> Unit, + timedMessageAllowed: Boolean = false, + customDisappearingMessageTimePref: SharedPreference? = null, + sendMessage: (Int?) -> Unit, + sendLiveMessage: (suspend () -> Unit)? = null, + updateLiveMessage: (suspend () -> Unit)? = null, + cancelLiveMessage: (() -> Unit)? = null, + onMessageChange: (String) -> Unit, + textStyle: MutableState +) { + val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } + + if (showCustomDisappearingMessageDialog.value) { + CustomDisappearingMessageDialog( + sendMessage = sendMessage, + setShowDialog = { showCustomDisappearingMessageDialog.value = it }, + customDisappearingMessageTimePref = customDisappearingMessageTimePref + ) + } + + Box(Modifier.padding(vertical = 8.dp)) { + val cs = composeState.value + val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview) + val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && + cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } + NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) + // Disable clicks on text field + if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) { + Box( + Modifier + .matchParentSize() + .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.observer_cant_send_message_title), + text = generalGetString(MR.strings.observer_cant_send_message_desc) + ) + }) + ) + } + if (showDeleteTextButton.value) { + DeleteTextButton(composeState) + } + Box(Modifier.align(Alignment.BottomEnd)) { + val sendButtonSize = remember { Animatable(36f) } + val sendButtonAlpha = remember { Animatable(1f) } + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + // Making LiveMessage alive when screen orientation was changed + if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + } + } + when { + showProgress -> ProgressIndicator() + showVoiceButton -> { + Row(verticalAlignment = Alignment.CenterVertically) { + val stopRecOnNextClick = remember { mutableStateOf(false) } + when { + needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> { + DisallowedVoiceButton(userCanSend) { + if (needToAllowVoiceToContact) { + showNeedToAllowVoiceAlert(allowVoiceToContact) + } else if (!allowedVoiceByPrefs) { + showDisabledVoiceAlert(isDirectChat) + } + } + } + !allowedToRecordVoiceByPlatform() -> + VoiceButtonWithoutPermissionByPlatform() + else -> + RecordVoiceView(recState, stopRecOnNextClick) + } + if (sendLiveMessage != null + && updateLiveMessage != null + && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) + && cs.contextItem is ComposeContextItem.NoContextItem + ) { + Spacer(Modifier.width(10.dp)) + StartLiveMessageButton(userCanSend) { + if (composeState.value.preview is ComposePreview.NoPreview) { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + } + } + } + } + } + cs.liveMessage?.sent == false && cs.message.isEmpty() -> { + CancelLiveMessageButton { + cancelLiveMessage?.invoke() + } + } + else -> { + val cs = composeState.value + val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward) + val disabled = !cs.sendEnabled() || + (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || + cs.endLiveDisabled + val showDropdown = rememberSaveable { mutableStateOf(false) } + + @Composable + fun MenuItems(): List<@Composable () -> Unit> { + val menuItems = mutableListOf<@Composable () -> Unit>() + + if (cs.liveMessage == null && !cs.editing) { + if ( + cs.preview !is ComposePreview.VoicePreview && + cs.contextItem is ComposeContextItem.NoContextItem && + sendLiveMessage != null && updateLiveMessage != null + ) { + menuItems.add { + ItemAction( + generalGetString(MR.strings.send_live_message), + BoltFilled, + onClick = { + startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) + showDropdown.value = false + } + ) + } + } + if (timedMessageAllowed) { + menuItems.add { + ItemAction( + generalGetString(MR.strings.disappearing_message), + painterResource(MR.images.ic_timer), + onClick = { + showCustomDisappearingMessageDialog.value = true + showDropdown.value = false + } + ) + } + } + } + + return menuItems + } + + val menuItems = MenuItems() + if (menuItems.isNotEmpty()) { + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true } + DefaultDropdownMenu(showDropdown) { + menuItems.forEach { composable -> composable() } + } + } else { + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) + } + } + } + } + } +} + +@Composable +private fun CustomDisappearingMessageDialog( + sendMessage: (Int?) -> Unit, + setShowDialog: (Boolean) -> Unit, + customDisappearingMessageTimePref: SharedPreference? +) { + val showCustomTimePicker = remember { mutableStateOf(false) } + + if (showCustomTimePicker.value) { + val selectedDisappearingMessageTime = remember { + mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300) + } + CustomTimePickerDialog( + selectedDisappearingMessageTime, + title = generalGetString(MR.strings.delete_after), + confirmButtonText = generalGetString(MR.strings.send_disappearing_message_send), + confirmButtonAction = { ttl -> + sendMessage(ttl) + customDisappearingMessageTimePref?.set?.invoke(ttl) + setShowDialog(false) + }, + cancel = { setShowDialog(false) } + ) + } else { + @Composable + fun ChoiceButton( + text: String, + onClick: () -> Unit + ) { + TextButton(onClick) { + Text( + text, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + } + + Dialog(onDismissRequest = { setShowDialog(false) }) { + Surface( + shape = RoundedCornerShape(corner = CornerSize(25.dp)) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(" ") // centers title + Text( + generalGetString(MR.strings.send_disappearing_message), + fontSize = 16.sp, + color = MaterialTheme.colors.secondary + ) + Icon( + painterResource(MR.images.ic_close), + generalGetString(MR.strings.icon_descr_close_button), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(25.dp) + .clickable { setShowDialog(false) } + ) + } + + ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) { + sendMessage(30) + setShowDialog(false) + } + ChoiceButton(generalGetString(MR.strings.send_disappearing_message_1_minute)) { + sendMessage(60) + setShowDialog(false) + } + ChoiceButton(generalGetString(MR.strings.send_disappearing_message_5_minutes)) { + sendMessage(300) + setShowDialog(false) + } + ChoiceButton(generalGetString(MR.strings.send_disappearing_message_custom_time)) { + showCustomTimePicker.value = true + } + } + } + } + } + } +} + +@Composable +private fun BoxScope.DeleteTextButton(composeState: MutableState) { + IconButton( + { composeState.value = composeState.value.copy(message = "") }, + Modifier.align(Alignment.TopEnd).size(36.dp) + ) { + Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary) + } +} + +@Composable +private fun RecordVoiceView(recState: MutableState, stopRecOnNextClick: MutableState) { + val rec: Recorder = remember { RecorderNative() } + DisposableEffect(Unit) { onDispose { rec.stop() } } + val stopRecordingAndAddAudio: () -> Unit = { + recState.value.filePathNullable?.let { + recState.value = RecordingState.Finished(it, rec.stop()) + } + } + if (stopRecOnNextClick.value) { + LaunchedEffect(recState.value) { + if (recState.value is RecordingState.NotStarted) { + stopRecOnNextClick.value = false + } + } + // Lock orientation to current orientation because screen rotation will break the recording + LockToCurrentOrientationUntilDispose() + StopRecordButton(stopRecordingAndAddAudio) + } else { + val startRecording: () -> Unit = { + recState.value = RecordingState.Started( + filePath = rec.start { progress: Int?, finished: Boolean -> + val state = recState.value + if (state is RecordingState.Started && progress != null) { + recState.value = if (!finished) + RecordingState.Started(state.filePath, progress) + else + RecordingState.Finished(state.filePath, progress) + } + }, + ) + } + val interactionSource = interactionSourceWithTapDetection( + onPress = { if (recState.value is RecordingState.NotStarted) startRecording() }, + onClick = { + if (stopRecOnNextClick.value) { + stopRecordingAndAddAudio() + } else { + // tapped and didn't hold a finger + stopRecOnNextClick.value = true + } + }, + onCancel = stopRecordingAndAddAudio, + onRelease = stopRecordingAndAddAudio + ) + RecordVoiceButton(interactionSource) + } +} + +@Composable +private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) { + IconButton(onClick, Modifier.size(36.dp), enabled = enabled) { + Icon( + painterResource(MR.images.ic_keyboard_voice), + stringResource(MR.strings.icon_descr_record_voice_message), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + ) + } +} + +@Composable +fun VoiceButtonWithoutPermission(onClick: () -> Unit) { + IconButton(onClick, Modifier.size(36.dp)) { + Icon( + painterResource(MR.images.ic_keyboard_voice_filled), + stringResource(MR.strings.icon_descr_record_voice_message), + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(34.dp) + .padding(4.dp) + ) + } +} + +@Composable +private fun StopRecordButton(onClick: () -> Unit) { + IconButton(onClick, Modifier.size(36.dp)) { + Icon( + painterResource(MR.images.ic_stop_filled), + stringResource(MR.strings.icon_descr_record_voice_message), + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + ) + } +} + +@Composable +private fun RecordVoiceButton(interactionSource: MutableInteractionSource) { + IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) { + Icon( + painterResource(MR.images.ic_keyboard_voice_filled), + stringResource(MR.strings.icon_descr_record_voice_message), + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(34.dp) + .padding(4.dp) + ) + } +} + +@Composable +private fun ProgressIndicator() { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) +} + +@Composable +private fun CancelLiveMessageButton( + onClick: () -> Unit +) { + IconButton(onClick, Modifier.size(36.dp)) { + Icon( + painterResource(MR.images.ic_close), + stringResource(MR.strings.icon_descr_cancel_live_message), + tint = MaterialTheme.colors.primary, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + ) + } +} + +@Composable +private fun SendMsgButton( + icon: Painter, + sizeDp: Animatable, + alpha: Animatable, + enabled: Boolean, + sendMessage: (Int?) -> Unit, + onLongClick: (() -> Unit)? = null +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier.requiredSize(36.dp) + .combinedClickable( + onClick = { sendMessage(null) }, + onLongClick = onLongClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple(bounded = false, radius = 24.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + stringResource(MR.strings.icon_descr_send_message), + tint = Color.White, + modifier = Modifier + .size(sizeDp.value.dp) + .padding(4.dp) + .alpha(alpha.value) + .clip(CircleShape) + .background(if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) + .padding(3.dp) + ) + } +} + +@Composable +private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = Modifier.requiredSize(36.dp) + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple(bounded = false, radius = 24.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + BoltFilled, + stringResource(MR.strings.icon_descr_send_message), + tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(36.dp) + .padding(4.dp) + ) + } +} + +private fun startLiveMessage( + scope: CoroutineScope, + send: suspend () -> Unit, + update: suspend () -> Unit, + sendButtonSize: Animatable, + sendButtonAlpha: Animatable, + composeState: MutableState, + liveMessageAlertShown: SharedPreference +) { + fun run() { + scope.launch { + while (composeState.value.liveMessage != null) { + sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50)) + } + sendButtonSize.snapTo(36f) + } + scope.launch { + while (composeState.value.liveMessage != null) { + sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50)) + } + sendButtonAlpha.snapTo(1f) + } + scope.launch { + delay(3000) + while (composeState.value.liveMessage != null) { + update() + delay(3000) + } + } + } + + fun start() = withBGApi { + if (composeState.value.liveMessage == null) { + send() + } + run() + } + + if (liveMessageAlertShown.state.value) { + start() + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.live_message), + text = generalGetString(MR.strings.send_live_message_desc), + confirmText = generalGetString(MR.strings.send_verb), + onConfirm = { + liveMessageAlertShown.set(true) + start() + }) + } +} + +private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.allow_voice_messages_question), + text = generalGetString(MR.strings.you_need_to_allow_to_send_voice), + confirmText = generalGetString(MR.strings.allow_verb), + dismissText = generalGetString(MR.strings.cancel_verb), + onConfirm = onConfirm, + ) +} + +private fun showDisabledVoiceAlert(isDirectChat: Boolean) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.voice_messages_prohibited), + text = generalGetString( + if (isDirectChat) + MR.strings.ask_your_contact_to_enable_voice + else + MR.strings.only_group_owners_can_enable_voice + ) + ) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSendMsgView() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + SimpleXTheme { + SendMsgView( + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + showVoiceRecordIcon = false, + recState = remember { mutableStateOf(RecordingState.NotStarted) }, + isDirectChat = true, + liveMessageAlertShown = SharedPreference(get = { true }, set = { }), + needToAllowVoiceToContact = false, + allowedVoiceByPrefs = true, + userIsObserver = false, + userCanSend = true, + allowVoiceToContact = {}, + timedMessageAllowed = false, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle + ) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSendMsgViewEditing() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true) + SimpleXTheme { + SendMsgView( + composeState = remember { mutableStateOf(composeStateEditing) }, + showVoiceRecordIcon = false, + recState = remember { mutableStateOf(RecordingState.NotStarted) }, + isDirectChat = true, + liveMessageAlertShown = SharedPreference(get = { true }, set = { }), + needToAllowVoiceToContact = false, + allowedVoiceByPrefs = true, + userIsObserver = false, + userCanSend = true, + allowVoiceToContact = {}, + timedMessageAllowed = false, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle + ) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSendMsgViewInProgress() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt", getAppFileUri("test.txt")), inProgress = true, useLinkPreviews = true) + SimpleXTheme { + SendMsgView( + composeState = remember { mutableStateOf(composeStateInProgress) }, + showVoiceRecordIcon = false, + recState = remember { mutableStateOf(RecordingState.NotStarted) }, + isDirectChat = true, + liveMessageAlertShown = SharedPreference(get = { true }, set = { }), + needToAllowVoiceToContact = false, + allowedVoiceByPrefs = true, + userIsObserver = false, + userCanSend = true, + allowVoiceToContact = {}, + timedMessageAllowed = false, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle + ) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 4c2bf1ca27..eac0bc3b85 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -1,814 +1,17 @@ package chat.simplex.app.views.chat import android.Manifest -import android.annotation.SuppressLint -import android.app.Activity -import android.content.Context -import android.content.pm.ActivityInfo -import android.content.res.Configuration -import android.os.Build -import android.text.InputType -import android.util.Log -import android.view.ViewGroup -import android.view.WindowManager -import android.view.inputmethod.* -import android.widget.EditText -import android.widget.TextView -import androidx.compose.animation.core.* -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.* -import androidx.compose.material.* -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.* -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.* -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.* -import androidx.compose.ui.res.* -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.* -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.* -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog -import androidx.core.graphics.drawable.DrawableCompat -import androidx.core.view.inputmethod.EditorInfoCompat -import androidx.core.view.inputmethod.InputConnectionCompat -import androidx.core.widget.* -import chat.simplex.app.R -import chat.simplex.app.SimplexApp -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.item.ItemAction -import chat.simplex.app.views.helpers.* +import androidx.compose.runtime.Composable import com.google.accompanist.permissions.rememberMultiplePermissionsState -import chat.simplex.res.MR -import dev.icerock.moko.resources.StringResource -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.* -import java.lang.reflect.Field @Composable -fun SendMsgView( - composeState: MutableState, - showVoiceRecordIcon: Boolean, - recState: MutableState, - isDirectChat: Boolean, - liveMessageAlertShown: SharedPreference, - needToAllowVoiceToContact: Boolean, - allowedVoiceByPrefs: Boolean, - userIsObserver: Boolean, - userCanSend: Boolean, - allowVoiceToContact: () -> Unit, - timedMessageAllowed: Boolean = false, - customDisappearingMessageTimePref: SharedPreference? = null, - sendMessage: (Int?) -> Unit, - sendLiveMessage: (suspend () -> Unit)? = null, - updateLiveMessage: (suspend () -> Unit)? = null, - cancelLiveMessage: (() -> Unit)? = null, - onMessageChange: (String) -> Unit, - textStyle: MutableState -) { - val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } - - if (showCustomDisappearingMessageDialog.value) { - CustomDisappearingMessageDialog( - sendMessage = sendMessage, - setShowDialog = { showCustomDisappearingMessageDialog.value = it }, - customDisappearingMessageTimePref = customDisappearingMessageTimePref - ) - } - - Box(Modifier.padding(vertical = 8.dp)) { - val cs = composeState.value - val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview) - val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) - val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) - // Disable clicks on text field - if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) { - Box( - Modifier - .matchParentSize() - .clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.observer_cant_send_message_title), - text = generalGetString(MR.strings.observer_cant_send_message_desc) - ) - }) - ) - } - if (showDeleteTextButton.value) { - DeleteTextButton(composeState) - } - Box(Modifier.align(Alignment.BottomEnd)) { - val sendButtonSize = remember { Animatable(36f) } - val sendButtonAlpha = remember { Animatable(1f) } - val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO)) - val scope = rememberCoroutineScope() - LaunchedEffect(Unit) { - // Making LiveMessage alive when screen orientation was changed - if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) { - startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) - } - } - when { - showProgress -> ProgressIndicator() - showVoiceButton -> { - Row(verticalAlignment = Alignment.CenterVertically) { - val stopRecOnNextClick = remember { mutableStateOf(false) } - when { - needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> { - DisallowedVoiceButton(userCanSend) { - if (needToAllowVoiceToContact) { - showNeedToAllowVoiceAlert(allowVoiceToContact) - } else if (!allowedVoiceByPrefs) { - showDisabledVoiceAlert(isDirectChat) - } - } - } - !permissionsState.allPermissionsGranted -> - VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() } - else -> - RecordVoiceView(recState, stopRecOnNextClick) - } - if (sendLiveMessage != null - && updateLiveMessage != null - && (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value) - && cs.contextItem is ComposeContextItem.NoContextItem - ) { - Spacer(Modifier.width(10.dp)) - StartLiveMessageButton(userCanSend) { - if (composeState.value.preview is ComposePreview.NoPreview) { - startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) - } - } - } - } - } - cs.liveMessage?.sent == false && cs.message.isEmpty() -> { - CancelLiveMessageButton { - cancelLiveMessage?.invoke() - } - } - else -> { - val cs = composeState.value - val icon = if (cs.editing || cs.liveMessage != null) painterResource(MR.images.ic_check_filled) else painterResource(MR.images.ic_arrow_upward) - val disabled = !cs.sendEnabled() || - (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || - cs.endLiveDisabled - val showDropdown = rememberSaveable { mutableStateOf(false) } - - @Composable - fun MenuItems(): List<@Composable () -> Unit> { - val menuItems = mutableListOf<@Composable () -> Unit>() - - if (cs.liveMessage == null && !cs.editing) { - if ( - cs.preview !is ComposePreview.VoicePreview && - cs.contextItem is ComposeContextItem.NoContextItem && - sendLiveMessage != null && updateLiveMessage != null - ) { - menuItems.add { - ItemAction( - generalGetString(MR.strings.send_live_message), - BoltFilled, - onClick = { - startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) - showDropdown.value = false - } - ) - } - } - if (timedMessageAllowed) { - menuItems.add { - ItemAction( - generalGetString(MR.strings.disappearing_message), - painterResource(MR.images.ic_timer), - onClick = { - showCustomDisappearingMessageDialog.value = true - showDropdown.value = false - } - ) - } - } - } - - return menuItems - } - - val menuItems = MenuItems() - if (menuItems.isNotEmpty()) { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true } - DefaultDropdownMenu(showDropdown) { - menuItems.forEach { composable -> composable() } - } - } else { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) - } - } - } - } - } +fun allowedToRecordVoiceByPlatform(): Boolean { + val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO)) + return permissionsState.allPermissionsGranted } @Composable -private fun CustomDisappearingMessageDialog( - sendMessage: (Int?) -> Unit, - setShowDialog: (Boolean) -> Unit, - customDisappearingMessageTimePref: SharedPreference? -) { - val showCustomTimePicker = remember { mutableStateOf(false) } - - if (showCustomTimePicker.value) { - val selectedDisappearingMessageTime = remember { - mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300) - } - CustomTimePickerDialog( - selectedDisappearingMessageTime, - title = generalGetString(MR.strings.delete_after), - confirmButtonText = generalGetString(MR.strings.send_disappearing_message_send), - confirmButtonAction = { ttl -> - sendMessage(ttl) - customDisappearingMessageTimePref?.set?.invoke(ttl) - setShowDialog(false) - }, - cancel = { setShowDialog(false) } - ) - } else { - @Composable - fun ChoiceButton( - text: String, - onClick: () -> Unit - ) { - TextButton(onClick) { - Text( - text, - fontSize = 18.sp, - color = MaterialTheme.colors.primary - ) - } - } - - Dialog(onDismissRequest = { setShowDialog(false) }) { - Surface( - shape = RoundedCornerShape(corner = CornerSize(25.dp)) - ) { - Box( - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(" ") // centers title - Text( - generalGetString(MR.strings.send_disappearing_message), - fontSize = 16.sp, - color = MaterialTheme.colors.secondary - ) - Icon( - painterResource(MR.images.ic_close), - generalGetString(MR.strings.icon_descr_close_button), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(25.dp) - .clickable { setShowDialog(false) } - ) - } - - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) { - sendMessage(30) - setShowDialog(false) - } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_1_minute)) { - sendMessage(60) - setShowDialog(false) - } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_5_minutes)) { - sendMessage(300) - setShowDialog(false) - } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_custom_time)) { - showCustomTimePicker.value = true - } - } - } - } - } - } -} - -@Composable -private fun NativeKeyboard( - composeState: MutableState, - textStyle: MutableState, - showDeleteTextButton: MutableState, - userIsObserver: Boolean, - onMessageChange: (String) -> Unit -) { - val cs = composeState.value - val textColor = MaterialTheme.colors.onBackground - val tintColor = MaterialTheme.colors.secondaryVariant - val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp) - val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() } - val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() } - val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() } - val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() } - var showKeyboard by remember { mutableStateOf(false) } - LaunchedEffect(cs.contextItem) { - if (cs.contextItem is ComposeContextItem.QuotedItem) { - delay(100) - showKeyboard = true - } else if (cs.contextItem is ComposeContextItem.EditingItem) { - // Keyboard will not show up if we try to show it too fast - delay(300) - showKeyboard = true - } - } - - AndroidView(modifier = Modifier, factory = { - val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) { - override fun setOnReceiveContentListener( - mimeTypes: Array?, - listener: android.view.OnReceiveContentListener? - ) { - super.setOnReceiveContentListener(mimeTypes, listener) - } - - override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { - val connection = super.onCreateInputConnection(editorInfo) - EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) - val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ -> - try { - inputContentInfo.requestPermission() - } catch (e: Exception) { - return@OnCommitContentListener false - } - SimplexApp.context.chatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri)) - true - } - return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit) - } - } - editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - editText.maxLines = 16 - editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType - editText.setTextColor(textColor.toArgb()) - editText.textSize = textStyle.value.fontSize.value - val drawable = it.getDrawable(R.drawable.send_msg_view_background)!! - DrawableCompat.setTint(drawable, tintColor.toArgb()) - editText.background = drawable - editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom) - editText.setText(cs.message) - if (Build.VERSION.SDK_INT >= 29) { - editText.textCursorDrawable?.let { DrawableCompat.setTint(it, CurrentColors.value.colors.secondary.toArgb()) } - } else { - try { - val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes") - f.isAccessible = true - f.set(editText, R.drawable.edit_text_cursor) - } catch (e: Exception) { - Log.e(chat.simplex.app.TAG, e.stackTraceToString()) - } - } - editText.doOnTextChanged { text, _, _, _ -> - if (!composeState.value.inProgress) { - onMessageChange(text.toString()) - } else if (text.toString() != composeState.value.message) { - editText.setText(composeState.value.message) - } - } - editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") } - editText - }) { - it.setTextColor(textColor.toArgb()) - it.textSize = textStyle.value.fontSize.value - DrawableCompat.setTint(it.background, tintColor.toArgb()) - it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview - it.isFocusableInTouchMode = it.isFocusable - if (cs.message != it.text.toString()) { - it.setText(cs.message) - // Set cursor to the end of the text - it.setSelection(it.text.length) - } - if (showKeyboard) { - it.requestFocus() - val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT) - showKeyboard = false - } - showDeleteTextButton.value = it.lineCount >= 4 && !cs.inProgress - } - if (composeState.value.preview is ComposePreview.VoicePreview) { - ComposeOverlay(MR.strings.voice_message_send_text, textStyle, padding) - } else if (userIsObserver) { - ComposeOverlay(MR.strings.you_are_observer, textStyle, padding) - } -} - -@Composable -private fun ComposeOverlay(textId: StringResource, textStyle: MutableState, padding: PaddingValues) { - Text( - generalGetString(textId), - Modifier.padding(padding), - color = MaterialTheme.colors.secondary, - style = textStyle.value.copy(fontStyle = FontStyle.Italic) - ) -} - -@Composable -private fun BoxScope.DeleteTextButton(composeState: MutableState) { - IconButton( - { composeState.value = composeState.value.copy(message = "") }, - Modifier.align(Alignment.TopEnd).size(36.dp) - ) { - Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary) - } -} - -@Composable -private fun RecordVoiceView(recState: MutableState, stopRecOnNextClick: MutableState) { - val rec: Recorder = remember { RecorderNative() } - DisposableEffect(Unit) { onDispose { rec.stop() } } - val stopRecordingAndAddAudio: () -> Unit = { - recState.value.filePathNullable?.let { - recState.value = RecordingState.Finished(it, rec.stop()) - } - } - if (stopRecOnNextClick.value) { - LaunchedEffect(recState.value) { - if (recState.value is RecordingState.NotStarted) { - stopRecOnNextClick.value = false - } - } - // Lock orientation to current orientation because screen rotation will break the recording - LockToCurrentOrientationUntilDispose() - StopRecordButton(stopRecordingAndAddAudio) - } else { - val startRecording: () -> Unit = { - recState.value = RecordingState.Started( - filePath = rec.start { progress: Int?, finished: Boolean -> - val state = recState.value - if (state is RecordingState.Started && progress != null) { - recState.value = if (!finished) - RecordingState.Started(state.filePath, progress) - else - RecordingState.Finished(state.filePath, progress) - } - }, - ) - } - val interactionSource = interactionSourceWithTapDetection( - onPress = { if (recState.value is RecordingState.NotStarted) startRecording() }, - onClick = { - if (stopRecOnNextClick.value) { - stopRecordingAndAddAudio() - } else { - // tapped and didn't hold a finger - stopRecOnNextClick.value = true - } - }, - onCancel = stopRecordingAndAddAudio, - onRelease = stopRecordingAndAddAudio - ) - RecordVoiceButton(interactionSource) - } -} - -@Composable -private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) { - IconButton(onClick, Modifier.size(36.dp), enabled = enabled) { - Icon( - painterResource(MR.images.ic_keyboard_voice), - stringResource(MR.strings.icon_descr_record_voice_message), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(36.dp) - .padding(4.dp) - ) - } -} - -@Composable -private fun VoiceButtonWithoutPermission(onClick: () -> Unit) { - IconButton(onClick, Modifier.size(36.dp)) { - Icon( - painterResource(MR.images.ic_keyboard_voice_filled), - stringResource(MR.strings.icon_descr_record_voice_message), - tint = MaterialTheme.colors.primary, - modifier = Modifier - .size(34.dp) - .padding(4.dp) - ) - } -} - -@Composable -private fun LockToCurrentOrientationUntilDispose() { - val context = LocalContext.current - DisposableEffect(Unit) { - val activity = context as Activity - val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager - val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation - activity.requestedOrientation = when (rotation) { - android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - // Unlock orientation - onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } - } -} - -@Composable -private fun StopRecordButton(onClick: () -> Unit) { - IconButton(onClick, Modifier.size(36.dp)) { - Icon( - painterResource(MR.images.ic_stop_filled), - stringResource(MR.strings.icon_descr_record_voice_message), - tint = MaterialTheme.colors.primary, - modifier = Modifier - .size(36.dp) - .padding(4.dp) - ) - } -} - -@Composable -private fun RecordVoiceButton(interactionSource: MutableInteractionSource) { - IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) { - Icon( - painterResource(MR.images.ic_keyboard_voice_filled), - stringResource(MR.strings.icon_descr_record_voice_message), - tint = MaterialTheme.colors.primary, - modifier = Modifier - .size(34.dp) - .padding(4.dp) - ) - } -} - -@Composable -private fun ProgressIndicator() { - CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) -} - -@Composable -private fun CancelLiveMessageButton( - onClick: () -> Unit -) { - IconButton(onClick, Modifier.size(36.dp)) { - Icon( - painterResource(MR.images.ic_close), - stringResource(MR.strings.icon_descr_cancel_live_message), - tint = MaterialTheme.colors.primary, - modifier = Modifier - .size(36.dp) - .padding(4.dp) - ) - } -} - -@Composable -private fun SendMsgButton( - icon: Painter, - sizeDp: Animatable, - alpha: Animatable, - enabled: Boolean, - sendMessage: (Int?) -> Unit, - onLongClick: (() -> Unit)? = null -) { - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = Modifier.requiredSize(36.dp) - .combinedClickable( - onClick = { sendMessage(null) }, - onLongClick = onLongClick, - enabled = enabled, - role = Role.Button, - interactionSource = interactionSource, - indication = rememberRipple(bounded = false, radius = 24.dp) - ), - contentAlignment = Alignment.Center - ) { - Icon( - icon, - stringResource(MR.strings.icon_descr_send_message), - tint = Color.White, - modifier = Modifier - .size(sizeDp.value.dp) - .padding(4.dp) - .alpha(alpha.value) - .clip(CircleShape) - .background(if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) - .padding(3.dp) - ) - } -} - -@Composable -private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = Modifier.requiredSize(36.dp) - .clickable( - onClick = onClick, - enabled = enabled, - role = Role.Button, - interactionSource = interactionSource, - indication = rememberRipple(bounded = false, radius = 24.dp) - ), - contentAlignment = Alignment.Center - ) { - Icon( - BoltFilled, - stringResource(MR.strings.icon_descr_send_message), - tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier - .size(36.dp) - .padding(4.dp) - ) - } -} - -private fun startLiveMessage( - scope: CoroutineScope, - send: suspend () -> Unit, - update: suspend () -> Unit, - sendButtonSize: Animatable, - sendButtonAlpha: Animatable, - composeState: MutableState, - liveMessageAlertShown: SharedPreference -) { - fun run() { - scope.launch { - while (composeState.value.liveMessage != null) { - sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50)) - } - sendButtonSize.snapTo(36f) - } - scope.launch { - while (composeState.value.liveMessage != null) { - sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50)) - } - sendButtonAlpha.snapTo(1f) - } - scope.launch { - delay(3000) - while (composeState.value.liveMessage != null) { - update() - delay(3000) - } - } - } - - fun start() = withBGApi { - if (composeState.value.liveMessage == null) { - send() - } - run() - } - - if (liveMessageAlertShown.state.value) { - start() - } else { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.live_message), - text = generalGetString(MR.strings.send_live_message_desc), - confirmText = generalGetString(MR.strings.send_verb), - onConfirm = { - liveMessageAlertShown.set(true) - start() - }) - } -} - -private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.allow_voice_messages_question), - text = generalGetString(MR.strings.you_need_to_allow_to_send_voice), - confirmText = generalGetString(MR.strings.allow_verb), - dismissText = generalGetString(MR.strings.cancel_verb), - onConfirm = onConfirm, - ) -} - -private fun showDisabledVoiceAlert(isDirectChat: Boolean) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.voice_messages_prohibited), - text = generalGetString( - if (isDirectChat) - MR.strings.ask_your_contact_to_enable_voice - else - MR.strings.only_group_owners_can_enable_voice - ) - ) -} - -@Preview(showBackground = true) -@Preview( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -) -@Composable -fun PreviewSendMsgView() { - val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - val textStyle = remember { mutableStateOf(smallFont) } - SimpleXTheme { - SendMsgView( - composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - showVoiceRecordIcon = false, - recState = remember { mutableStateOf(RecordingState.NotStarted) }, - isDirectChat = true, - liveMessageAlertShown = SharedPreference(get = { true }, set = { }), - needToAllowVoiceToContact = false, - allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, - allowVoiceToContact = {}, - timedMessageAllowed = false, - sendMessage = {}, - onMessageChange = { _ -> }, - textStyle = textStyle - ) - } -} - -@Preview(showBackground = true) -@Preview( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -) -@Composable -fun PreviewSendMsgViewEditing() { - val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - val textStyle = remember { mutableStateOf(smallFont) } - val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData(), useLinkPreviews = true) - SimpleXTheme { - SendMsgView( - composeState = remember { mutableStateOf(composeStateEditing) }, - showVoiceRecordIcon = false, - recState = remember { mutableStateOf(RecordingState.NotStarted) }, - isDirectChat = true, - liveMessageAlertShown = SharedPreference(get = { true }, set = { }), - needToAllowVoiceToContact = false, - allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, - allowVoiceToContact = {}, - timedMessageAllowed = false, - sendMessage = {}, - onMessageChange = { _ -> }, - textStyle = textStyle - ) - } -} - -@Preview(showBackground = true) -@Preview( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -) -@Composable -fun PreviewSendMsgViewInProgress() { - val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - val textStyle = remember { mutableStateOf(smallFont) } - val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt", getAppFileUri("test.txt")), inProgress = true, useLinkPreviews = true) - SimpleXTheme { - SendMsgView( - composeState = remember { mutableStateOf(composeStateInProgress) }, - showVoiceRecordIcon = false, - recState = remember { mutableStateOf(RecordingState.NotStarted) }, - isDirectChat = true, - liveMessageAlertShown = SharedPreference(get = { true }, set = { }), - needToAllowVoiceToContact = false, - allowedVoiceByPrefs = true, - userIsObserver = false, - userCanSend = true, - allowVoiceToContact = {}, - timedMessageAllowed = false, - sendMessage = {}, - onMessageChange = { _ -> }, - textStyle = textStyle - ) - } +fun VoiceButtonWithoutPermissionByPlatform() { + val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO)) + VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt index 9a122f360b..390b839937 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt @@ -15,7 +15,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.QRCode diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt index 555d6c4a2e..8a60681031 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalView @@ -25,8 +24,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.hideKeyboard import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.ChatInfoToolbarTitle import chat.simplex.app.views.helpers.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt index 025251cd8b..ce1efdeafe 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt @@ -14,8 +14,8 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.QRCode diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index 8c17466312..c1bc15dbd9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -24,8 +24,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.* import chat.simplex.app.views.helpers.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt index 5031babd49..1be6cca41e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.cropToSquare +import chat.simplex.app.platform.resizeImageToStrSize import chat.simplex.app.ui.theme.* import chat.simplex.app.views.ProfileNameField import chat.simplex.app.views.helpers.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt index 7cd7c9ca38..2e04171ea2 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt @@ -18,9 +18,8 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.copyText import chat.simplex.app.ui.theme.DEFAULT_PADDING import chat.simplex.app.views.chat.item.MarkdownText import chat.simplex.app.views.helpers.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index 7c9e49766b..21e6346eca 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -1,6 +1,10 @@ package chat.simplex.app.views.chat.item +import android.net.Uri import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -19,12 +23,15 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R +import chat.simplex.app.SimplexApp import chat.simplex.app.model.* +import chat.simplex.app.platform.getLoadedFilePath import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.res.MR import kotlinx.datetime.Clock +import java.io.BufferedOutputStream +import java.io.File @Composable fun CIFileView( @@ -206,6 +213,30 @@ fun CIFileView( } } +@Composable +fun rememberSaveFileLauncher(ciFile: CIFile?): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + destination?.let { + val cxt = SimplexApp.context + val filePath = getLoadedFilePath(ciFile) + if (filePath != null) { + val contentResolver = cxt.contentResolver + contentResolver.openOutputStream(destination)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + File(filePath).inputStream().use { it.copyTo(outputStream) } + outputStream.close() + Toast.makeText(cxt, generalGetString(MR.strings.file_saved), Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(cxt, generalGetString(MR.strings.file_not_found), Toast.LENGTH_SHORT).show() + } + } + } + ) + + class ChatItemProvider: PreviewParameterProvider { private val sentFile = ChatItem( chatDir = CIDirection.DirectSnd(), diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.common.kt new file mode 100644 index 0000000000..35bc7ad55c --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.common.kt @@ -0,0 +1,188 @@ +package chat.simplex.app.views.chat.item + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import chat.simplex.app.BuildConfig +import chat.simplex.app.model.* +import chat.simplex.app.platform.base64ToBitmap +import chat.simplex.app.platform.getLoadedFilePath +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.io.File + +@Composable +fun CIImageView( + image: String, + file: CIFile?, + imageProvider: () -> ImageGalleryProvider, + showMenu: MutableState, + receiveFile: (Long) -> Unit +) { + @Composable + fun progressIndicator() { + CircularProgressIndicator( + Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } + + @Composable + fun fileIcon(icon: Painter, stringId: StringResource) { + Icon( + icon, + stringResource(stringId), + Modifier.fillMaxSize(), + tint = Color.White + ) + } + + @Composable + fun loadingIndicator() { + if (file != null) { + Box( + Modifier + .padding(8.dp) + .size(20.dp), + contentAlignment = Alignment.Center + ) { + when (file.fileStatus) { + is CIFileStatus.SndStored -> + when (file.fileProtocol) { + FileProtocol.XFTP -> progressIndicator() + FileProtocol.SMP -> {} + } + is CIFileStatus.SndTransfer -> progressIndicator() + is CIFileStatus.SndComplete -> fileIcon(painterResource(MR.images.ic_check_filled), MR.strings.icon_descr_image_snd_complete) + is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive) + is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_image) + is CIFileStatus.RcvTransfer -> progressIndicator() + is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + else -> {} + } + } + } + } + + @Composable + fun imageViewFullWidth(): Dp { + val approximatePadding = 100.dp + return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) } + } + + @Composable + fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) { + Image( + imageBitmap.asImageBitmap(), + contentDescription = stringResource(MR.strings.image_descr), + // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView + // if text is short and take all available width if text is long + modifier = Modifier + .width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = onClick + ), + contentScale = ContentScale.FillWidth, + ) + } + + @Composable + fun imageView(painter: Painter, onClick: () -> Unit) { + Image( + painter, + contentDescription = stringResource(MR.strings.image_descr), + // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView + // if text is short and take all available width if text is long + modifier = Modifier + .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = onClick + ), + contentScale = ContentScale.FillWidth, + ) + } + + fun fileSizeValid(): Boolean { + if (file != null) { + return file.fileSize <= getMaxFileSize(file.fileProtocol) + } + return false + } + + fun imageAndFilePath(file: CIFile?): Pair { + val imageBitmap: Bitmap? = getLoadedImage(file) + val filePath = getLoadedFilePath(file) + return imageBitmap to filePath + } + + Box( + Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), + contentAlignment = Alignment.TopEnd + ) { + val context = LocalContext.current + val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) } + if (imageBitmap != null && filePath != null) { + val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) } + SimpleAndAnimatedImageView(uri, imageBitmap.asImageBitmap(), file, imageProvider, @Composable { painter, onClick -> imageView(painter, onClick) }) + } else { + imageView(base64ToBitmap(image), onClick = { + if (file != null) { + when (file.fileStatus) { + CIFileStatus.RcvInvitation -> + if (fileSizeValid()) { + receiveFile(file.fileId) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + ) + } + CIFileStatus.RcvAccepted -> + when (file.fileProtocol) { + FileProtocol.XFTP -> + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.waiting_for_image), + generalGetString(MR.strings.image_will_be_received_when_contact_completes_uploading) + ) + FileProtocol.SMP -> + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.waiting_for_image), + generalGetString(MR.strings.image_will_be_received_when_contact_is_online) + ) + } + CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ? + CIFileStatus.RcvComplete -> {} // ? + CIFileStatus.RcvCancelled -> {} // TODO + else -> {} + } + } + }) + } + loadingIndicator() + } +} + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index 642757f271..3c3337db88 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -1,213 +1,50 @@ package chat.simplex.app.views.chat.item -import android.graphics.Bitmap -import android.os.Build.VERSION.SDK_INT -import androidx.compose.foundation.Image -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.* -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon +import android.net.Uri +import android.os.Build import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.* -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.core.content.FileProvider import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.getLoadedFilePath +import chat.simplex.app.platform.hideKeyboard import chat.simplex.app.views.helpers.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest -import chat.simplex.res.MR -import dev.icerock.moko.resources.StringResource -import java.io.File @Composable -fun CIImageView( - image: String, +fun SimpleAndAnimatedImageView( + uri: Uri, + imageBitmap: ImageBitmap, file: CIFile?, imageProvider: () -> ImageGalleryProvider, - showMenu: MutableState, - receiveFile: (Long) -> Unit + ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { - @Composable - fun progressIndicator() { - CircularProgressIndicator( - Modifier.size(16.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } - - @Composable - fun fileIcon(icon: Painter, stringId: StringResource) { - Icon( - icon, - stringResource(stringId), - Modifier.fillMaxSize(), - tint = Color.White - ) - } - - @Composable - fun loadingIndicator() { - if (file != null) { - Box( - Modifier - .padding(8.dp) - .size(20.dp), - contentAlignment = Alignment.Center - ) { - when (file.fileStatus) { - is CIFileStatus.SndStored -> - when (file.fileProtocol) { - FileProtocol.XFTP -> progressIndicator() - FileProtocol.SMP -> {} - } - is CIFileStatus.SndTransfer -> progressIndicator() - is CIFileStatus.SndComplete -> fileIcon(painterResource(MR.images.ic_check_filled), MR.strings.icon_descr_image_snd_complete) - is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive) - is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_image) - is CIFileStatus.RcvTransfer -> progressIndicator() - is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - else -> {} - } + val imagePainter = rememberAsyncImagePainter( + ImageRequest.Builder(SimplexApp.context).data(data = uri).size(coil.size.Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil + imageLoader = imageLoader + ) + val view = LocalView.current + ImageView(imagePainter) { + hideKeyboard(view) + if (getLoadedFilePath(file) != null) { + ModalManager.shared.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) } } } - - @Composable - fun imageViewFullWidth(): Dp { - val approximatePadding = 100.dp - return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) } - } - - @Composable - fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) { - Image( - imageBitmap.asImageBitmap(), - contentDescription = stringResource(MR.strings.image_descr), - // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView - // if text is short and take all available width if text is long - modifier = Modifier - .width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp) - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = onClick - ), - contentScale = ContentScale.FillWidth, - ) - } - - @Composable - fun imageView(painter: Painter, onClick: () -> Unit) { - Image( - painter, - contentDescription = stringResource(MR.strings.image_descr), - // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView - // if text is short and take all available width if text is long - modifier = Modifier - .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp) - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = onClick - ), - contentScale = ContentScale.FillWidth, - ) - } - - fun fileSizeValid(): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - - fun imageAndFilePath(file: CIFile?): Pair { - val imageBitmap: Bitmap? = getLoadedImage(file) - val filePath = getLoadedFilePath(file) - return imageBitmap to filePath - } - - Box( - Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), - contentAlignment = Alignment.TopEnd - ) { - val context = LocalContext.current - val (imageBitmap, filePath) = remember(file) { imageAndFilePath(file) } - if (imageBitmap != null && filePath != null) { - val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) } - val imagePainter = rememberAsyncImagePainter( - ImageRequest.Builder(context).data(data = uri).size(coil.size.Size.ORIGINAL).build(), - placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil - imageLoader = imageLoader - ) - val view = LocalView.current - imageView(imagePainter, onClick = { - hideKeyboard(view) - if (getLoadedFilePath(file) != null) { - ModalManager.shared.showCustomModal(animated = false) { close -> - ImageFullScreenView(imageProvider, close) - } - } - }) - } else { - imageView(base64ToBitmap(image), onClick = { - if (file != null) { - when (file.fileStatus) { - CIFileStatus.RcvInvitation -> - if (fileSizeValid()) { - receiveFile(file.fileId) - } else { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) - ) - } - CIFileStatus.RcvAccepted -> - when (file.fileProtocol) { - FileProtocol.XFTP -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.waiting_for_image), - generalGetString(MR.strings.image_will_be_received_when_contact_completes_uploading) - ) - FileProtocol.SMP -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.waiting_for_image), - generalGetString(MR.strings.image_will_be_received_when_contact_is_online) - ) - } - CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ? - CIFileStatus.RcvComplete -> {} // ? - CIFileStatus.RcvCancelled -> {} // TODO - else -> {} - } - } - }) - } - loadingIndicator() - } } private val imageLoader = ImageLoader.Builder(SimplexApp.context) .components { - if (SDK_INT >= 28) { + if (Build.VERSION.SDK_INT >= 28) { add(ImageDecoderDecoder.Factory()) } else { add(GifDecoder.Factory()) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt index 64111ee4ea..dc0ef7f415 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt @@ -11,7 +11,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp -import chat.simplex.app.R +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.DEFAULT_PADDING import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.SettingsActionItem diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVIdeoView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVIdeoView.common.kt new file mode 100644 index 0000000000..6a421c6348 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVIdeoView.common.kt @@ -0,0 +1,318 @@ +package chat.simplex.app.views.chat.item + +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.* +import androidx.compose.ui.unit.* +import androidx.core.content.FileProvider +import chat.simplex.app.BuildConfig +import chat.simplex.app.model.* +import chat.simplex.app.platform.* +import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.app.ui.theme.WarningOrange +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.io.File + +@Composable +fun CIVideoView( + image: String, + duration: Int, + file: CIFile?, + imageProvider: () -> ImageGalleryProvider, + showMenu: MutableState, + receiveFile: (Long) -> Unit +) { + Box( + Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), + contentAlignment = Alignment.TopEnd + ) { + val context = LocalContext.current + val filePath = remember(file) { getLoadedFilePath(file) } + val preview = remember(image) { base64ToBitmap(image) } + if (file != null && filePath != null) { + val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) } + val view = LocalView.current + VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { + hideKeyboard(view) + ModalManager.shared.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + }) + } else { + Box { + ImageView(preview, showMenu, onClick = { + if (file != null) { + when (file.fileStatus) { + CIFileStatus.RcvInvitation -> + receiveFileIfValidSize(file, receiveFile) + CIFileStatus.RcvAccepted -> + when (file.fileProtocol) { + FileProtocol.XFTP -> + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.waiting_for_video), + generalGetString(MR.strings.video_will_be_received_when_contact_completes_uploading) + ) + + FileProtocol.SMP -> + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.waiting_for_video), + generalGetString(MR.strings.video_will_be_received_when_contact_is_online) + ) + } + CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ? + CIFileStatus.RcvComplete -> {} // ? + CIFileStatus.RcvCancelled -> {} // TODO + else -> {} + } + } + }) + if (file != null) { + DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) + } + if (file?.fileStatus is CIFileStatus.RcvInvitation) { + PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + } + } + } + loadingIndicator(file) + } +} + +@Composable +private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState, onClick: () -> Unit) { + val context = LocalContext.current + val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } + val videoPlaying = remember(uri.path) { player.videoPlaying } + val progress = remember(uri.path) { player.progress } + val duration = remember(uri.path) { player.duration } + val preview by remember { player.preview } + // val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled } + val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo } + val play = { + player.enableSound(true) + player.play(true) + } + val stop = { + player.enableSound(false) + player.stop() + } + val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } + DisposableEffect(Unit) { + onDispose { + stop() + } + } + Box { + val windowWidth = LocalWindowWidth() + val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp } + PlayerView( + player, + width, + onClick = onClick, + onLongClick = { showMenu.value = true }, + stop + ) + if (showPreview.value) { + ImageView(preview, showMenu, onClick) + PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play) + } + DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) + } +} + +@Composable +private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) { + Surface( + Modifier.align(Alignment.Center), + color = Color.Black.copy(alpha = 0.25f), + shape = RoundedCornerShape(percent = 50) + ) { + Box( + Modifier + .defaultMinSize(minWidth = 40.dp, minHeight = 40.dp) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(MR.images.ic_play_arrow_filled), + contentDescription = null, + tint = if (error) WarningOrange else Color.White + ) + } + } +} + +@Composable +private fun DurationProgress(file: CIFile, playing: MutableState, duration: MutableState, progress: MutableState/*, soundEnabled: MutableState*/) { + if (duration.value > 0L || progress.value > 0) { + Row { + Box( + Modifier + .padding(DEFAULT_PADDING_HALF) + .background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50)) + .padding(vertical = 2.dp, horizontal = 4.dp) + ) { + val time = if (progress.value > 0) progress.value else duration.value + val timeStr = durationText((time / 1000).toInt()) + val width = if (timeStr.length <= 5) 44 else 50 + Text( + timeStr, + Modifier.widthIn(min = with(LocalDensity.current) { width.sp.toDp() }).padding(horizontal = 4.dp), + fontSize = 13.sp, + color = Color.White + ) + /*if (!soundEnabled.value) { + Icon(painterResource(MR.images.ic_volume_off_filled), null, + Modifier.padding(start = 5.dp).size(10.dp), + tint = Color.White + ) + }*/ + } + if (!playing.value) { + Box( + Modifier + .padding(top = DEFAULT_PADDING_HALF) + .background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50)) + .padding(vertical = 2.dp, horizontal = 4.dp) + ) { + Text( + formatBytes(file.fileSize), + Modifier.padding(horizontal = 4.dp), + fontSize = 13.sp, + color = Color.White + ) + } + } + } + } +} + +@Composable +private fun ImageView(preview: Bitmap, showMenu: MutableState, onClick: () -> Unit) { + val windowWidth = LocalWindowWidth() + val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp } + Image( + preview.asImageBitmap(), + contentDescription = stringResource(MR.strings.video_descr), + modifier = Modifier + .width(width) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = onClick + ), + contentScale = ContentScale.FillWidth, + ) +} + +@Composable +private fun progressIndicator() { + CircularProgressIndicator( + Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) +} + +@Composable +private fun fileIcon(icon: Painter, stringId: StringResource) { + Icon( + icon, + stringResource(stringId), + Modifier.fillMaxSize(), + tint = Color.White + ) +} + +@Composable +private fun progressCircle(progress: Long, total: Long) { + val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat() + val strokeWidth = with(LocalDensity.current) { 2.dp.toPx() } + val strokeColor = Color.White + Surface( + Modifier.drawRingModifier(angle, strokeColor, strokeWidth), + color = Color.Transparent, + shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)) + ) { + Box(Modifier.size(16.dp)) + } +} + +@Composable +private fun loadingIndicator(file: CIFile?) { + if (file != null) { + Box( + Modifier + .padding(8.dp) + .size(20.dp), + contentAlignment = Alignment.Center + ) { + when (file.fileStatus) { + is CIFileStatus.SndStored -> + when (file.fileProtocol) { + FileProtocol.XFTP -> progressIndicator() + FileProtocol.SMP -> {} + } + is CIFileStatus.SndTransfer -> + when (file.fileProtocol) { + FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal) + FileProtocol.SMP -> progressIndicator() + } + is CIFileStatus.SndComplete -> fileIcon(painterResource(MR.images.ic_check_filled), MR.strings.icon_descr_video_snd_complete) + is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) + is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_video) + is CIFileStatus.RcvTransfer -> + if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { + progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal) + } else { + progressIndicator() + } + is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) + else -> {} + } + } + } +} + +private fun fileSizeValid(file: CIFile?): Boolean { + if (file != null) { + return file.fileSize <= getMaxFileSize(file.fileProtocol) + } + return false +} + +private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { + if (fileSizeValid(file)) { + receiveFile(file.fileId) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.large_file), + String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + ) + } +} + +private fun videoViewFullWidth(windowWidth: Dp): Dp { + val approximatePadding = 100.dp + return minOf(1000.dp, windowWidth - approximatePadding) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt index f6123e7bed..585c1eb229 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt @@ -1,239 +1,38 @@ package chat.simplex.app.views.chat.item -import android.graphics.Bitmap import android.graphics.Rect -import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.* -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.* import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.FileProvider -import androidx.core.graphics.drawable.toDrawable -import chat.simplex.app.* -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH import com.google.android.exoplayer2.ui.StyledPlayerView -import chat.simplex.res.MR -import dev.icerock.moko.resources.StringResource -import java.io.File + @Composable -fun CIVideoView( - image: String, - duration: Int, - file: CIFile?, - imageProvider: () -> ImageGalleryProvider, - showMenu: MutableState, - receiveFile: (Long) -> Unit -) { - Box( - Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), - contentAlignment = Alignment.TopEnd - ) { - val context = LocalContext.current - val filePath = remember(file) { getLoadedFilePath(file) } - val preview = remember(image) { base64ToBitmap(image) } - if (file != null && filePath != null) { - val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) } - val view = LocalView.current - VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { - hideKeyboard(view) - ModalManager.shared.showCustomModal(animated = false) { close -> - ImageFullScreenView(imageProvider, close) - } - }) - } else { - Box { - ImageView(preview, showMenu, onClick = { - if (file != null) { - when (file.fileStatus) { - CIFileStatus.RcvInvitation -> - receiveFileIfValidSize(file, receiveFile) - CIFileStatus.RcvAccepted -> - when (file.fileProtocol) { - FileProtocol.XFTP -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.waiting_for_video), - generalGetString(MR.strings.video_will_be_received_when_contact_completes_uploading) - ) - - FileProtocol.SMP -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.waiting_for_video), - generalGetString(MR.strings.video_will_be_received_when_contact_is_online) - ) - } - CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ? - CIFileStatus.RcvComplete -> {} // ? - CIFileStatus.RcvCancelled -> {} // TODO - else -> {} - } - } - }) - if (file != null) { - DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) - } - if (file?.fileStatus is CIFileStatus.RcvInvitation) { - PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } - } +fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { + AndroidView( + factory = { ctx -> + StyledPlayerView(ctx).apply { + useController = false + resizeMode = RESIZE_MODE_FIXED_WIDTH + this.player = player.player } - } - loadingIndicator(file) - } -} - -@Composable -private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState, onClick: () -> Unit) { - val context = LocalContext.current - val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } - val videoPlaying = remember(uri.path) { player.videoPlaying } - val progress = remember(uri.path) { player.progress } - val duration = remember(uri.path) { player.duration } - val preview by remember { player.preview } -// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled } - val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo } - val play = { - player.enableSound(true) - player.play(true) - } - val stop = { - player.enableSound(false) - player.stop() - } - val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } - DisposableEffect(Unit) { - onDispose { - stop() - } - } - Box { - val windowWidth = LocalWindowWidth() - val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp } - AndroidView( - factory = { ctx -> - StyledPlayerView(ctx).apply { - useController = false - resizeMode = RESIZE_MODE_FIXED_WIDTH - this.player = player.player - } - }, - Modifier - .width(width) - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = { if (player.player.playWhenReady) stop() else onClick() } - ) - ) - if (showPreview.value) { - ImageView(preview, showMenu, onClick) - PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play) - } - DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) - } -} - -@Composable -private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) { - Surface( - Modifier.align(Alignment.Center), - color = Color.Black.copy(alpha = 0.25f), - shape = RoundedCornerShape(percent = 50) - ) { - Box( - Modifier - .defaultMinSize(minWidth = 40.dp, minHeight = 40.dp) - .combinedClickable(onClick = onClick, onLongClick = onLongClick), - contentAlignment = Alignment.Center - ) { - Icon( - painterResource(MR.images.ic_play_arrow_filled), - contentDescription = null, - tint = if (error) WarningOrange else Color.White - ) - } - } -} - -@Composable -private fun DurationProgress(file: CIFile, playing: MutableState, duration: MutableState, progress: MutableState/*, soundEnabled: MutableState*/) { - if (duration.value > 0L || progress.value > 0) { - Row { - Box( - Modifier - .padding(DEFAULT_PADDING_HALF) - .background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50)) - .padding(vertical = 2.dp, horizontal = 4.dp) - ) { - val time = if (progress.value > 0) progress.value else duration.value - val timeStr = durationText((time / 1000).toInt()) - val width = if (timeStr.length <= 5) 44 else 50 - Text( - timeStr, - Modifier.widthIn(min = with(LocalDensity.current) { width.sp.toDp() }).padding(horizontal = 4.dp), - fontSize = 13.sp, - color = Color.White - ) - /*if (!soundEnabled.value) { - Icon(painterResource(MR.images.ic_volume_off_filled), null, - Modifier.padding(start = 5.dp).size(10.dp), - tint = Color.White - ) - }*/ - } - if (!playing.value) { - Box( - Modifier - .padding(top = DEFAULT_PADDING_HALF) - .background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50)) - .padding(vertical = 2.dp, horizontal = 4.dp) - ) { - Text( - formatBytes(file.fileSize), - Modifier.padding(horizontal = 4.dp), - fontSize = 13.sp, - color = Color.White - ) - } - } - } - } -} - -@Composable -private fun ImageView(preview: Bitmap, showMenu: MutableState, onClick: () -> Unit) { - val windowWidth = LocalWindowWidth() - val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp } - Image( - preview.asImageBitmap(), - contentDescription = stringResource(MR.strings.video_descr), - modifier = Modifier + }, + Modifier .width(width) .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = onClick - ), - contentScale = ContentScale.FillWidth, + onLongClick = onLongClick, + onClick = { if (player.player.playWhenReady) stop() else onClick() } + ) ) } -@Composable -private fun LocalWindowWidth(): Dp { +@Composable fun LocalWindowWidth(): Dp { val view = LocalView.current val density = LocalDensity.current.density return remember { @@ -242,98 +41,3 @@ private fun LocalWindowWidth(): Dp { (rect.width() / density).dp } } - -@Composable -private fun progressIndicator() { - CircularProgressIndicator( - Modifier.size(16.dp), - color = Color.White, - strokeWidth = 2.dp - ) -} - -@Composable -private fun fileIcon(icon: Painter, stringId: StringResource) { - Icon( - icon, - stringResource(stringId), - Modifier.fillMaxSize(), - tint = Color.White - ) -} - -@Composable -private fun progressCircle(progress: Long, total: Long) { - val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat() - val strokeWidth = with(LocalDensity.current) { 2.dp.toPx() } - val strokeColor = Color.White - Surface( - Modifier.drawRingModifier(angle, strokeColor, strokeWidth), - color = Color.Transparent, - shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)) - ) { - Box(Modifier.size(16.dp)) - } -} - -@Composable -private fun loadingIndicator(file: CIFile?) { - if (file != null) { - Box( - Modifier - .padding(8.dp) - .size(20.dp), - contentAlignment = Alignment.Center - ) { - when (file.fileStatus) { - is CIFileStatus.SndStored -> - when (file.fileProtocol) { - FileProtocol.XFTP -> progressIndicator() - FileProtocol.SMP -> {} - } - is CIFileStatus.SndTransfer -> - when (file.fileProtocol) { - FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal) - FileProtocol.SMP -> progressIndicator() - } - is CIFileStatus.SndComplete -> fileIcon(painterResource(MR.images.ic_check_filled), MR.strings.icon_descr_video_snd_complete) - is CIFileStatus.SndCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) - is CIFileStatus.RcvAccepted -> fileIcon(painterResource(MR.images.ic_more_horiz), MR.strings.icon_descr_waiting_for_video) - is CIFileStatus.RcvTransfer -> - if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { - progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal) - } else { - progressIndicator() - } - is CIFileStatus.RcvCancelled -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - is CIFileStatus.RcvError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file) - else -> {} - } - } - } -} - -private fun fileSizeValid(file: CIFile?): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false -} - -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { - if (fileSizeValid(file)) { - receiveFile(file.fileId) - } else { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.large_file), - String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) - ) - } -} - -private fun videoViewFullWidth(windowWidth: Dp): Dp { - val approximatePadding = 100.dp - return minOf(1000.dp, windowWidth - approximatePadding) -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index 8d331c2276..de1cd602d3 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.* import androidx.compose.ui.unit.* import chat.simplex.app.model.* +import chat.simplex.app.platform.getLoadedFilePath import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.res.MR diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.common.kt new file mode 100644 index 0000000000..2e2c1676e5 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.common.kt @@ -0,0 +1,533 @@ +package chat.simplex.app.views.chat.item + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.* +import chat.simplex.app.platform.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.* +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import kotlinx.datetime.Clock + +// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code) + +@Composable +fun ChatItemView( + cInfo: ChatInfo, + cItem: ChatItem, + composeState: MutableState, + imageProvider: (() -> ImageGalleryProvider)? = null, + showMember: Boolean = false, + useLinkPreviews: Boolean, + linkMode: SimplexLinkMode, + deleteMessage: (Long, CIDeleteMode) -> Unit, + receiveFile: (Long) -> Unit, + cancelFile: (Long) -> Unit, + joinGroup: (Long) -> Unit, + acceptCall: (Contact) -> Unit, + scrollToItem: (Long) -> Unit, + acceptFeature: (Contact, ChatFeature, Int?) -> Unit, + setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, + showItemDetails: (ChatInfo, ChatItem) -> Unit, +) { + val uriHandler = LocalUriHandler.current + val sent = cItem.chatDir.sent + val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart + val showMenu = remember { mutableStateOf(false) } + val revealed = remember { mutableStateOf(false) } + val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } + val onLinkLongClick = { _: String -> showMenu.value = true } + val live = composeState.value.liveMessage != null + + Box( + modifier = Modifier + .padding(bottom = 4.dp) + .fillMaxWidth(), + contentAlignment = alignment, + ) { + val onClick = { + when (cItem.meta.itemStatus) { + is CIStatus.SndErrorAuth -> { + showMsgDeliveryErrorAlert(generalGetString(MR.strings.message_delivery_error_desc)) + } + is CIStatus.SndError -> { + showMsgDeliveryErrorAlert(generalGetString(MR.strings.unknown_error) + ": ${cItem.meta.itemStatus.agentError}") + } + else -> {} + } + } + + @Composable + fun ChatItemReactions() { + Row { + cItem.reactions.forEach { r -> + var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) + if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) { + modifier = modifier.clickable { + setReaction(cInfo, cItem, !r.userReacted, r.reaction) + } + } + Row(modifier.padding(2.dp)) { + Text(r.reaction.text, fontSize = 12.sp) + if (r.totalReacted > 1) { + Spacer(Modifier.width(4.dp)) + Text("${r.totalReacted}", + fontSize = 11.5.sp, + fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal, + color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + ) + } + } + } + } + } + + Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { + Column( + Modifier + .clip(RoundedCornerShape(18.dp)) + .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick), + ) { + @Composable + fun framedItemView() { + FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem) + } + + fun deleteMessageQuestionText(): String { + return if (fullDeleteAllowed) { + generalGetString(MR.strings.delete_message_cannot_be_undone_warning) + } else { + generalGetString(MR.strings.delete_message_mark_deleted_warning) + } + } + + fun moderateMessageQuestionText(): String { + return if (fullDeleteAllowed) { + generalGetString(MR.strings.moderate_message_will_be_deleted_warning) + } else { + generalGetString(MR.strings.moderate_message_will_be_marked_warning) + } + } + + @Composable + fun MsgReactionsMenu() { + val rs = MsgReaction.values.mapNotNull { r -> + if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { + r + } else { + null + } + } + if (rs.isNotEmpty()) { + Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState())) { + rs.forEach() { r -> + Box( + Modifier.size(36.dp).clickable { + setReaction(cInfo, cItem, true, r) + showMenu.value = false + }, + contentAlignment = Alignment.Center + ) { + Text(r.text) + } + } + } + } + } + + @Composable + fun MsgContentItemDropdownMenu() { + DefaultDropdownMenu(showMenu) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() + } + if (cItem.meta.itemDeleted == null && !live) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + val filePath = getLoadedFilePath(cItem.file) + when { + filePath != null -> shareFile(cItem.text, filePath) + else -> shareText(cItem.content.text) + } + showMenu.value = false + }) + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyText(cItem.content.text) + showMenu.value = false + }) + if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) { + val filePath = getLoadedFilePath(cItem.file) + if (filePath != null) { + SaveContentItemAction(cItem, showMenu) + } + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + showMenu.value = false + }) + } + if (cItem.meta.itemDeleted != null && revealed.value) { + ItemAction( + stringResource(MR.strings.hide_verb), + painterResource(MR.images.ic_visibility_off), + onClick = { + revealed.value = false + showMenu.value = false + } + ) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + } + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } + } + } + + @Composable + fun MarkedDeletedItemDropdownMenu() { + DefaultDropdownMenu(showMenu) { + if (!cItem.isDeletedContent) { + ItemAction( + stringResource(MR.strings.reveal_verb), + painterResource(MR.images.ic_visibility), + onClick = { + revealed.value = true + showMenu.value = false + } + ) + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + } + DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + } + } + + @Composable + fun ContentItem() { + val mc = cItem.content.msgContent + if (cItem.meta.itemDeleted != null && !revealed.value) { + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + MarkedDeletedItemDropdownMenu() + } else { + if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile) + } else { + framedItemView() + } + } else { + framedItemView() + } + MsgContentItemDropdownMenu() + } + } + + @Composable fun DeletedItem() { + DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + DefaultDropdownMenu(showMenu) { + DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + } + } + + @Composable fun CallItem(status: CICallStatus, duration: Int) { + CICallItemView(cInfo, cItem, status, duration, acceptCall) + } + + @Composable + fun ModeratedItem() { + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) + DefaultDropdownMenu(showMenu) { + DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage) + } + } + + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> DeletedItem() + is CIContent.RcvDeleted -> DeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember) + is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) + is CIContent.RcvGroupEventContent -> CIEventView(cItem) + is CIContent.SndGroupEventContent -> CIEventView(cItem) + is CIContent.RcvConnEventContent -> CIEventView(cItem) + is CIContent.SndConnEventContent -> CIEventView(cItem) + is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) + is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + } + is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,) + is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) + is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) + is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) + is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) + is CIContent.SndModerated -> ModeratedItem() + is CIContent.RcvModerated -> ModeratedItem() + is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) + } + } + + if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { + ChatItemReactions() + } + } + } +} + +@Composable +fun CancelFileItemAction( + fileId: Long, + showMenu: MutableState, + cancelFile: (Long) -> Unit, + cancelAction: CancelAction +) { + ItemAction( + stringResource(cancelAction.uiActionId), + painterResource(MR.images.ic_close), + onClick = { + showMenu.value = false + cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction) + }, + color = Color.Red + ) +} + +@Composable +fun ItemInfoAction( + cInfo: ChatInfo, + cItem: ChatItem, + showItemDetails: (ChatInfo, ChatItem) -> Unit, + showMenu: MutableState +) { + ItemAction( + stringResource(MR.strings.info_menu), + painterResource(MR.images.ic_info), + onClick = { + showItemDetails(cInfo, cItem) + showMenu.value = false + } + ) +} + + +@Composable +fun DeleteItemAction( + cItem: ChatItem, + showMenu: MutableState, + questionText: String, + deleteMessage: (Long, CIDeleteMode) -> Unit +) { + ItemAction( + stringResource(MR.strings.delete_verb), + painterResource(MR.images.ic_delete), + onClick = { + showMenu.value = false + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + }, + color = Color.Red + ) +} + +@Composable +fun ModerateItemAction( + cItem: ChatItem, + questionText: String, + showMenu: MutableState, + deleteMessage: (Long, CIDeleteMode) -> Unit +) { + ItemAction( + stringResource(MR.strings.moderate_verb), + painterResource(MR.images.ic_flag), + onClick = { + showMenu.value = false + moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + }, + color = Color.Red + ) +} + +@Composable +fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) { + val finalColor = if (color == Color.Unspecified) { + if (isInDarkTheme()) MenuTextColorDark else Color.Black + } else color + DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text, + modifier = Modifier + .fillMaxWidth() + .weight(1F) + .padding(end = 15.dp), + color = finalColor + ) + Icon(icon, text, tint = finalColor) + } + } +} + +@Composable +fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) { + val finalColor = if (color == Color.Unspecified) { + if (isInDarkTheme()) MenuTextColorDark else Color.Black + } else color + DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text, + modifier = Modifier + .fillMaxWidth() + .weight(1F) + .padding(end = 15.dp), + color = finalColor + ) + Icon(icon, text, tint = finalColor) + } + } +} + +fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { + AlertManager.shared.showAlertDialog( + title = generalGetString(cancelAction.alert.titleId), + text = generalGetString(cancelAction.alert.messageId), + confirmText = generalGetString(cancelAction.alert.confirmId), + destructive = true, + onConfirm = { + cancelFile(fileId) + } + ) +} + +fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.delete_message__question), + text = questionText, + buttons = { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + if (chatItem.meta.editable) { + Spacer(Modifier.padding(horizontal = 4.dp)) + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_everybody), color = MaterialTheme.colors.error) } + } + } + } + ) +} + +fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.delete_member_message__question), + text = questionText, + confirmText = generalGetString(MR.strings.delete_verb), + destructive = true, + onConfirm = { + deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) + } + ) +} + +private fun showMsgDeliveryErrorAlert(description: String) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.message_delivery_error_title), + text = description, + ) +} + +@Preview +@Composable +fun PreviewChatItemView() { + SimpleXTheme { + ChatItemView( + ChatInfo.Direct.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" + ), + useLinkPreviews = true, + linkMode = SimplexLinkMode.DESCRIPTION, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + deleteMessage = { _, _ -> }, + receiveFile = {}, + cancelFile = {}, + joinGroup = {}, + acceptCall = { _ -> }, + scrollToItem = {}, + acceptFeature = { _, _, _ -> }, + setReaction = { _, _, _, _ -> }, + showItemDetails = { _, _ -> }, + ) + } +} + +@Preview +@Composable +fun PreviewChatItemViewDeletedContent() { + SimpleXTheme { + ChatItemView( + ChatInfo.Direct.sampleData, + ChatItem.getDeletedContentSampleData(), + useLinkPreviews = true, + linkMode = SimplexLinkMode.DESCRIPTION, + composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + deleteMessage = { _, _ -> }, + receiveFile = {}, + cancelFile = {}, + joinGroup = {}, + acceptCall = { _ -> }, + scrollToItem = {}, + acceptFeature = { _, _, _ -> }, + setReaction = { _, _, _, _ -> }, + showItemDetails = { _, _ -> }, + ) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 93a4670dfe..ef0eb89c86 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -2,551 +2,32 @@ package chat.simplex.app.views.chat.item import android.Manifest import android.os.Build -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import chat.simplex.app.model.ChatItem +import chat.simplex.app.model.MsgContent +import chat.simplex.app.platform.saveImage +import chat.simplex.res.MR +import com.google.accompanist.permissions.rememberPermissionState import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.* -import chat.simplex.app.views.helpers.* -import com.google.accompanist.permissions.rememberPermissionState -import chat.simplex.res.MR -import kotlinx.datetime.Clock - -// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code) @Composable -fun ChatItemView( - cInfo: ChatInfo, - cItem: ChatItem, - composeState: MutableState, - imageProvider: (() -> ImageGalleryProvider)? = null, - showMember: Boolean = false, - useLinkPreviews: Boolean, - linkMode: SimplexLinkMode, - deleteMessage: (Long, CIDeleteMode) -> Unit, - receiveFile: (Long) -> Unit, - cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, - acceptCall: (Contact) -> Unit, - scrollToItem: (Long) -> Unit, - acceptFeature: (Contact, ChatFeature, Int?) -> Unit, - setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, - showItemDetails: (ChatInfo, ChatItem) -> Unit, -) { - val context = LocalContext.current - val uriHandler = LocalUriHandler.current - val sent = cItem.chatDir.sent - val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart - val showMenu = remember { mutableStateOf(false) } - val revealed = remember { mutableStateOf(false) } - val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } +fun SaveContentItemAction(cItem: ChatItem, showMenu: MutableState) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) - val onLinkLongClick = { _: String -> showMenu.value = true } - val live = composeState.value.liveMessage != null - - Box( - modifier = Modifier - .padding(bottom = 4.dp) - .fillMaxWidth(), - contentAlignment = alignment, - ) { - val onClick = { - when (cItem.meta.itemStatus) { - is CIStatus.SndErrorAuth -> { - showMsgDeliveryErrorAlert(generalGetString(MR.strings.message_delivery_error_desc)) - } - is CIStatus.SndError -> { - showMsgDeliveryErrorAlert(generalGetString(MR.strings.unknown_error) + ": ${cItem.meta.itemStatus.agentError}") - } - else -> {} - } - } - - @Composable - fun ChatItemReactions() { - Row { - cItem.reactions.forEach { r -> - var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) - if (cInfo.featureEnabled(ChatFeature.Reactions) && (cItem.allowAddReaction || r.userReacted)) { - modifier = modifier.clickable { - setReaction(cInfo, cItem, !r.userReacted, r.reaction) - } - } - Row(modifier.padding(2.dp)) { - Text(r.reaction.text, fontSize = 12.sp) - if (r.totalReacted > 1) { - Spacer(Modifier.width(4.dp)) - Text("${r.totalReacted}", - fontSize = 11.5.sp, - fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal, - color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - ) - } - } + val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) + ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + when (cItem.content.msgContent) { + is MsgContent.MCImage -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) { + saveImage(cItem.file) + } else { + writePermissionState.launchPermissionRequest() } } + is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName) + else -> {} } - - Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { - Column( - Modifier - .clip(RoundedCornerShape(18.dp)) - .combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick), - ) { - @Composable - fun framedItemView() { - FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem) - } - - fun deleteMessageQuestionText(): String { - return if (fullDeleteAllowed) { - generalGetString(MR.strings.delete_message_cannot_be_undone_warning) - } else { - generalGetString(MR.strings.delete_message_mark_deleted_warning) - } - } - - fun moderateMessageQuestionText(): String { - return if (fullDeleteAllowed) { - generalGetString(MR.strings.moderate_message_will_be_deleted_warning) - } else { - generalGetString(MR.strings.moderate_message_will_be_marked_warning) - } - } - - @Composable - fun MsgReactionsMenu() { - val rs = MsgReaction.values.mapNotNull { r -> - if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) { - r - } else { - null - } - } - if (rs.isNotEmpty()) { - Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState())) { - rs.forEach() { r -> - Box( - Modifier.size(36.dp).clickable { - setReaction(cInfo, cItem, true, r) - showMenu.value = false - }, - contentAlignment = Alignment.Center - ) { - Text(r.text) - } - } - } - } - } - - @Composable - fun MsgContentItemDropdownMenu() { - DefaultDropdownMenu(showMenu) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } - showMenu.value = false - }) - } - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val filePath = getLoadedFilePath(cItem.file) - when { - filePath != null -> shareFile(cItem.text, filePath) - else -> shareText(cItem.content.text) - } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyText(cItem.content.text) - showMenu.value = false - }) - if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) { - val filePath = getLoadedFilePath(cItem.file) - if (filePath != null) { - val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) - ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { - when (cItem.content.msgContent) { - is MsgContent.MCImage -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) { - saveImage(cItem.file) - } else { - writePermissionState.launchPermissionRequest() - } - } - is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName) - else -> {} - } - showMenu.value = false - }) - } - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) - showMenu.value = false - }) - } - if (cItem.meta.itemDeleted != null && revealed.value) { - ItemAction( - stringResource(MR.strings.hide_verb), - painterResource(MR.images.ic_visibility_off), - onClick = { - revealed.value = false - showMenu.value = false - } - ) - } - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (!(live && cItem.meta.isLive)) { - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) - } - } - } - - @Composable - fun MarkedDeletedItemDropdownMenu() { - DefaultDropdownMenu(showMenu) { - if (!cItem.isDeletedContent) { - ItemAction( - stringResource(MR.strings.reveal_verb), - painterResource(MR.images.ic_visibility), - onClick = { - revealed.value = true - showMenu.value = false - } - ) - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - } - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - } - - @Composable - fun ContentItem() { - val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && !revealed.value) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) - MarkedDeletedItemDropdownMenu() - } else { - if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL) - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile) - } else { - framedItemView() - } - } else { - framedItemView() - } - MsgContentItemDropdownMenu() - } - } - - @Composable fun DeletedItem() { - DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - } - - @Composable fun CallItem(status: CICallStatus, duration: Int) { - CICallItemView(cInfo, cItem, status, duration, acceptCall) - } - - @Composable - fun ModeratedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember) - DefaultDropdownMenu(showMenu) { - DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage) - } - } - - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> DeletedItem() - is CIContent.RcvDeleted -> DeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember) - is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) - is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) - is CIContent.RcvGroupEventContent -> CIEventView(cItem) - is CIContent.SndGroupEventContent -> CIEventView(cItem) - is CIContent.RcvConnEventContent -> CIEventView(cItem) - is CIContent.SndConnEventContent -> CIEventView(cItem) - is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) - is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - } - is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,) - is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) - is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) - is CIContent.SndModerated -> ModeratedItem() - is CIContent.RcvModerated -> ModeratedItem() - is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) - } - } - - if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { - ChatItemReactions() - } - } - } -} - -@Composable -fun CancelFileItemAction( - fileId: Long, - showMenu: MutableState, - cancelFile: (Long) -> Unit, - cancelAction: CancelAction -) { - ItemAction( - stringResource(cancelAction.uiActionId), - painterResource(MR.images.ic_close), - onClick = { - showMenu.value = false - cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction) - }, - color = Color.Red - ) -} - -@Composable -fun ItemInfoAction( - cInfo: ChatInfo, - cItem: ChatItem, - showItemDetails: (ChatInfo, ChatItem) -> Unit, - showMenu: MutableState -) { - ItemAction( - stringResource(MR.strings.info_menu), - painterResource(MR.images.ic_info), - onClick = { - showItemDetails(cInfo, cItem) - showMenu.value = false - } - ) -} - - -@Composable -fun DeleteItemAction( - cItem: ChatItem, - showMenu: MutableState, - questionText: String, - deleteMessage: (Long, CIDeleteMode) -> Unit -) { - ItemAction( - stringResource(MR.strings.delete_verb), - painterResource(MR.images.ic_delete), - onClick = { - showMenu.value = false - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) - }, - color = Color.Red - ) -} - -@Composable -fun ModerateItemAction( - cItem: ChatItem, - questionText: String, - showMenu: MutableState, - deleteMessage: (Long, CIDeleteMode) -> Unit -) { - ItemAction( - stringResource(MR.strings.moderate_verb), - painterResource(MR.images.ic_flag), - onClick = { - showMenu.value = false - moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) - }, - color = Color.Red - ) -} - -@Composable -fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) { - val finalColor = if (color == Color.Unspecified) { - if (isInDarkTheme()) MenuTextColorDark else Color.Black - } else color - DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text, - modifier = Modifier - .fillMaxWidth() - .weight(1F) - .padding(end = 15.dp), - color = finalColor - ) - Icon(icon, text, tint = finalColor) - } - } -} - -@Composable -fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) { - val finalColor = if (color == Color.Unspecified) { - if (isInDarkTheme()) MenuTextColorDark else Color.Black - } else color - DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text, - modifier = Modifier - .fillMaxWidth() - .weight(1F) - .padding(end = 15.dp), - color = finalColor - ) - Icon(icon, text, tint = finalColor) - } - } -} - -fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { - AlertManager.shared.showAlertDialog( - title = generalGetString(cancelAction.alert.titleId), - text = generalGetString(cancelAction.alert.messageId), - confirmText = generalGetString(cancelAction.alert.confirmId), - destructive = true, - onConfirm = { - cancelFile(fileId) - } - ) -} - -fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { - AlertManager.shared.showAlertDialogButtons( - title = generalGetString(MR.strings.delete_message__question), - text = questionText, - buttons = { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.Center, - ) { - TextButton(onClick = { - deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) - AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.editable) { - Spacer(Modifier.padding(horizontal = 4.dp)) - TextButton(onClick = { - deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) - AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_everybody), color = MaterialTheme.colors.error) } - } - } - } - ) -} - -fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.delete_member_message__question), - text = questionText, - confirmText = generalGetString(MR.strings.delete_verb), - destructive = true, - onConfirm = { - deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) - } - ) -} - -private fun showMsgDeliveryErrorAlert(description: String) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.message_delivery_error_title), - text = description, - ) -} - -@Preview -@Composable -fun PreviewChatItemView() { - SimpleXTheme { - ChatItemView( - ChatInfo.Direct.sampleData, - ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" - ), - useLinkPreviews = true, - linkMode = SimplexLinkMode.DESCRIPTION, - composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - deleteMessage = { _, _ -> }, - receiveFile = {}, - cancelFile = {}, - joinGroup = {}, - acceptCall = { _ -> }, - scrollToItem = {}, - acceptFeature = { _, _, _ -> }, - setReaction = { _, _, _, _ -> }, - showItemDetails = { _, _ -> }, - ) - } -} - -@Preview -@Composable -fun PreviewChatItemViewDeletedContent() { - SimpleXTheme { - ChatItemView( - ChatInfo.Direct.sampleData, - ChatItem.getDeletedContentSampleData(), - useLinkPreviews = true, - linkMode = SimplexLinkMode.DESCRIPTION, - composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, - deleteMessage = { _, _ -> }, - receiveFile = {}, - cancelFile = {}, - joinGroup = {}, - acceptCall = { _ -> }, - scrollToItem = {}, - acceptFeature = { _, _, _ -> }, - setReaction = { _, _, _, _ -> }, - showItemDetails = { _, _ -> }, - ) - } + showMenu.value = false + }) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 6dc7920235..f85db89106 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.unit.* import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.base64ToBitmap import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.res.MR diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.common.kt new file mode 100644 index 0000000000..d39baa29e2 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.common.kt @@ -0,0 +1,178 @@ +package chat.simplex.app.views.chat.item + +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.view.View +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.* +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.isVisible +import chat.simplex.app.platform.base64ToBitmap +import chat.simplex.app.views.chat.ProviderMedia +import chat.simplex.app.views.helpers.* +import coil.ImageLoader +import coil.compose.rememberAsyncImagePainter +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.request.ImageRequest +import coil.size.Size +import com.google.accompanist.pager.* +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.StyledPlayerView +import chat.simplex.res.MR +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +interface ImageGalleryProvider { + val initialIndex: Int + val totalMediaSize: MutableState + fun getMedia(index: Int): ProviderMedia? + fun currentPageChanged(index: Int) + fun scrollToStart() + fun onDismiss(index: Int) +} + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { + val provider = remember { imageProvider() } + val pagerState = rememberPagerState(provider.initialIndex) + val goBack = { provider.onDismiss(pagerState.currentPage); close() } + BackHandler(onBack = goBack) + // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, + // which makes this blank page visible for a moment. Prevent it by doing the check ourselves + LaunchedEffect(Unit) { + if (provider.getMedia(provider.initialIndex - 1) == null) { + provider.scrollToStart() + pagerState.scrollToPage(0) + } + } + val scope = rememberCoroutineScope() + val playersToRelease = rememberSaveable { mutableSetOf() } + DisposableEffectOnGone( + whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } } + ) + HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index -> + Column( + Modifier + .fillMaxSize() + .background(Color.Black) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack) + ) { + var settledCurrentPage by remember { mutableStateOf(pagerState.currentPage) } + LaunchedEffect(pagerState) { + snapshotFlow { + if (!pagerState.isScrollInProgress) pagerState.currentPage else settledCurrentPage + }.collect { + settledCurrentPage = it + } + } + LaunchedEffect(settledCurrentPage) { + // Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time + if (settledCurrentPage != provider.initialIndex) + provider.currentPageChanged(index) + } + val media = provider.getMedia(index) + if (media == null) { + // No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically + SideEffect { + scope.launch { + when (settledCurrentPage) { + index - 1 -> provider.totalMediaSize.value = settledCurrentPage + 1 + index + 1 -> { + provider.scrollToStart() + pagerState.scrollToPage(0) + } + } + } + } + } else { + var scale by remember { mutableStateOf(1f) } + var translationX by remember { mutableStateOf(0f) } + var translationY by remember { mutableStateOf(0f) } + var viewWidth by remember { mutableStateOf(0) } + var allowTranslate by remember { mutableStateOf(true) } + LaunchedEffect(settledCurrentPage) { + scale = 1f + translationX = 0f + translationY = 0f + } + val modifier = Modifier + .onGloballyPositioned { + viewWidth = it.size.width + } + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = translationX, + translationY = translationY, + ) + .pointerInput(Unit) { + detectTransformGestures( + { allowTranslate }, + onGesture = { _, pan, gestureZoom, _ -> + scale = (scale * gestureZoom).coerceIn(1f, 20f) + allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0 + if (scale > 1 && allowTranslate) { + translationX += pan.x * scale + translationY += pan.y * scale + } else if (allowTranslate) { + translationX = 0f + translationY = 0f + } + } + ) + } + .fillMaxSize() + if (media is ProviderMedia.Image) { + val (uri: Uri, imageBitmap: Bitmap) = media + FullScreenImageView(modifier, uri, imageBitmap) + } else if (media is ProviderMedia.Video) { + val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } + VideoView(modifier, media.uri, preview, index == settledCurrentPage) + DisposableEffect(Unit) { + onDispose { playersToRelease.add(media.uri) } + } + } + } + } + } +} + +@Composable +private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) { + val context = LocalContext.current + val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) } + val isCurrentPage = rememberUpdatedState(currentPage) + val play = { + player.play(true) + } + val stop = { + player.stop() + } + LaunchedEffect(Unit) { + player.enableSound(true) + snapshotFlow { isCurrentPage.value } + .distinctUntilChanged() + .collect { if (it) play() else stop() } + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + FullScreenVideoView(player, modifier) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt index 9cfaa3481f..861f2b8f66 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -4,216 +4,73 @@ import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.view.View -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.* -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.* +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible -import chat.simplex.app.R -import chat.simplex.app.views.chat.ProviderMedia -import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.VideoPlayer +import chat.simplex.res.MR import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest import coil.size.Size -import com.google.accompanist.pager.* +import com.google.android.exoplayer2.R import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView -import chat.simplex.res.MR -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch -import kotlin.math.absoluteValue +import dev.icerock.moko.resources.compose.stringResource -interface ImageGalleryProvider { - val initialIndex: Int - val totalMediaSize: MutableState - fun getMedia(index: Int): ProviderMedia? - fun currentPageChanged(index: Int) - fun scrollToStart() - fun onDismiss(index: Int) -} - -@OptIn(ExperimentalPagerApi::class) @Composable -fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { - val provider = remember { imageProvider() } - val pagerState = rememberPagerState(provider.initialIndex) - val goBack = { provider.onDismiss(pagerState.currentPage); close() } - BackHandler(onBack = goBack) - // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, - // which makes this blank page visible for a moment. Prevent it by doing the check ourselves - LaunchedEffect(Unit) { - if (provider.getMedia(provider.initialIndex - 1) == null) { - provider.scrollToStart() - pagerState.scrollToPage(0) - } - } - val scope = rememberCoroutineScope() - val playersToRelease = rememberSaveable { mutableSetOf() } - DisposableEffectOnGone( - whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } } - ) - HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index -> - Column( - Modifier - .fillMaxSize() - .background(Color.Black) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack) - ) { - var settledCurrentPage by remember { mutableStateOf(pagerState.currentPage) } - LaunchedEffect(pagerState) { - snapshotFlow { - if (!pagerState.isScrollInProgress) pagerState.currentPage else settledCurrentPage - }.collect { - settledCurrentPage = it - } - } - LaunchedEffect(settledCurrentPage) { - // Make this pager with infinity scrolling with only 3 pages at a time when left and right pages constructs in real time - if (settledCurrentPage != provider.initialIndex) - provider.currentPageChanged(index) - } - val media = provider.getMedia(index) - if (media == null) { - // No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically - SideEffect { - scope.launch { - when (settledCurrentPage) { - index - 1 -> provider.totalMediaSize.value = settledCurrentPage + 1 - index + 1 -> { - provider.scrollToStart() - pagerState.scrollToPage(0) - } - } - } - } +fun FullScreenImageView(modifier: Modifier, uri: Uri, imageBitmap: Bitmap) { + // I'm making a new instance of imageLoader here because if I use one instance in multiple places + // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want + val imageLoader = ImageLoader.Builder(LocalContext.current) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) } else { - var scale by remember { mutableStateOf(1f) } - var translationX by remember { mutableStateOf(0f) } - var translationY by remember { mutableStateOf(0f) } - var viewWidth by remember { mutableStateOf(0) } - var allowTranslate by remember { mutableStateOf(true) } - LaunchedEffect(settledCurrentPage) { - scale = 1f - translationX = 0f - translationY = 0f - } - val modifier = Modifier - .onGloballyPositioned { - viewWidth = it.size.width - } - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = translationX, - translationY = translationY, - ) - .pointerInput(Unit) { - detectTransformGestures( - { allowTranslate }, - onGesture = { _, pan, gestureZoom, _ -> - scale = (scale * gestureZoom).coerceIn(1f, 20f) - allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0 - if (scale > 1 && allowTranslate) { - translationX += pan.x * scale - translationY += pan.y * scale - } else if (allowTranslate) { - translationX = 0f - translationY = 0f - } - } - ) - } - .fillMaxSize() - if (media is ProviderMedia.Image) { - val (uri: Uri, imageBitmap: Bitmap) = media - // I'm making a new instance of imageLoader here because if I use one instance in multiple places - // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want - val imageLoader = ImageLoader.Builder(LocalContext.current) - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .build() - Image( - rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), - placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil - imageLoader = imageLoader - ), - contentDescription = stringResource(MR.strings.image_descr), - contentScale = ContentScale.Fit, - modifier = modifier, - ) - } else if (media is ProviderMedia.Video) { - val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } - VideoView(modifier, media.uri, preview, index == settledCurrentPage) - DisposableEffect(Unit) { - onDispose { playersToRelease.add(media.uri) } - } - } + add(GifDecoder.Factory()) } } - } + .build() + Image( + rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil + imageLoader = imageLoader + ), + contentDescription = stringResource(MR.strings.image_descr), + contentScale = ContentScale.Fit, + modifier = modifier, + ) } @Composable -private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) { - val context = LocalContext.current - val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) } - val isCurrentPage = rememberUpdatedState(currentPage) - val play = { - player.play(true) - } - val stop = { - player.stop() - } - LaunchedEffect(Unit) { - player.enableSound(true) - snapshotFlow { isCurrentPage.value } - .distinctUntilChanged() - .collect { if (it) play() else stop() } - } - - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AndroidView( - factory = { ctx -> - StyledPlayerView(ctx).apply { - resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) { - AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT - } else { - AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - } - setShowPreviousButton(false) - setShowNextButton(false) - setShowSubtitleButton(false) - setShowVrButton(false) - controllerAutoShow = false - findViewById(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb()) - findViewById(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false - this.player = player.player +fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) { + AndroidView( + factory = { ctx -> + StyledPlayerView(ctx).apply { + resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) { + AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH } - }, - modifier - ) - } + setShowPreviousButton(false) + setShowNextButton(false) + setShowSubtitleButton(false) + setShowVrButton(false) + controllerAutoShow = false + findViewById(R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb()) + findViewById(R.id.exo_settings).isVisible = false + this.player = player.player + } + }, + modifier + ) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index 3d93cab587..116106853c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -1,5 +1,7 @@ package chat.simplex.app.views.chatlist +import android.net.Uri +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -23,7 +25,7 @@ import chat.simplex.app.* import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.NewChatSheet +import chat.simplex.app.views.newchat.* import chat.simplex.app.views.onboarding.WhatsNewView import chat.simplex.app.views.onboarding.shouldShowWhatsNew import chat.simplex.app.views.usersettings.SettingsView @@ -306,6 +308,35 @@ private fun ProgressIndicator() { ) } +fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) { + Log.d(TAG, "connectIfOpenedViaUri: opened via link") + if (chatModel.currentUser.value == null) { + chatModel.appOpenUrl.value = uri + } else { + withUriAction(uri) { linkType -> + val title = when (linkType) { + ConnectionLinkType.CONTACT -> generalGetString(MR.strings.connect_via_contact_link) + ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link) + ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link) + } + AlertManager.shared.showAlertDialog( + title = title, + text = if (linkType == ConnectionLinkType.GROUP) + generalGetString(MR.strings.you_will_join_group) + else + generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link), + confirmText = generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { + withApi { + Log.d(TAG, "connectIfOpenedViaUri: connecting") + connectViaUri(chatModel, linkType, uri) + } + } + ) + } + } +} + private var lazyListState = 0 to 0 @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt index 69b8f49d1a..e7782f4d43 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt @@ -3,7 +3,6 @@ package chat.simplex.app.views.database import SectionBottomSpacer import SectionTextFooter import SectionView -import android.content.Context import android.content.res.Configuration import android.net.Uri import android.util.Log @@ -21,8 +20,8 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.tooling.preview.Preview import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.ChatModel +import chat.simplex.app.platform.getFilesDirectory import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt index cf97b3c8c4..7dabbfde12 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt @@ -20,10 +20,11 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.AppPreferences +import chat.simplex.app.model.NotificationsMode +import chat.simplex.app.platform.initChatController import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.AppVersionText -import chat.simplex.app.views.usersettings.NotificationsMode import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -199,7 +200,7 @@ private fun runChat( if (progressIndicator.value) return@launch progressIndicator.value = true try { - SimplexApp.context.initChatController(dbKey, confirmMigrations) + initChatController(dbKey, confirmMigrations) } catch (e: Exception) { Log.d(TAG, "initializeChat ${e.stackTraceToString()}") } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index f82caf7e78..b01ccd9856 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -5,7 +5,6 @@ import SectionDividerSpaced import SectionTextFooter import SectionItemView import SectionView -import android.content.Context import android.content.res.Configuration import android.net.Uri import android.util.Log @@ -28,10 +27,9 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentActivity import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.* @@ -357,7 +355,7 @@ private fun startChat(m: ChatModel, runChat: MutableState, chatLastSta withApi { try { if (chatDbChanged.value) { - SimplexApp.context.initChatController() + initChatController() chatDbChanged.value = false } if (m.chatDbStatus.value !is DBMigrationResult.OK) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 175d27c7c4..37bf8dd9dd 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.ChatInfo +import chat.simplex.app.platform.base64ToBitmap import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.common.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.kt rename to apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.common.kt index 71af738bda..7db325a110 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.common.kt @@ -8,10 +8,8 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import chat.simplex.app.ui.theme.* @Composable fun DefaultDropdownMenu( diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index c7c8154900..301ae120e7 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -29,86 +29,16 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.json -import chat.simplex.app.views.chat.PickFromGallery +import chat.simplex.app.platform.getAppFilesDirectory import chat.simplex.app.views.newchat.ActionButton import chat.simplex.res.MR import kotlinx.serialization.builtins.* -import java.io.ByteArrayOutputStream import java.io.File -import kotlin.math.min -import kotlin.math.sqrt - -// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery -fun cropToSquare(image: Bitmap): Bitmap { - var xOffset = 0 - var yOffset = 0 - val side = min(image.height, image.width) - if (image.height < image.width) { - xOffset = (image.width - side) / 2 - } else { - yOffset = (image.height - side) / 2 - } - return Bitmap.createBitmap(image, xOffset, yOffset, side, side) -} - -fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String { - var img = image - var str = compressImageStr(img) - while (str.length > maxDataSize) { - val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) - val clippedRatio = min(ratio, 2.0) - val width = (img.width.toDouble() / clippedRatio).toInt() - val height = img.height * width / img.width - img = Bitmap.createScaledBitmap(img, width, height, true) - str = compressImageStr(img) - } - return str -} - -private fun compressImageStr(bitmap: Bitmap): String { - val usePng = bitmap.hasAlpha() - val ext = if (usePng) "png" else "jpg" - return "data:image/$ext;base64," + Base64.encodeToString(compressImageData(bitmap, usePng).toByteArray(), Base64.NO_WRAP) -} - -fun resizeImageToDataSize(image: Bitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream { - var img = image - var stream = compressImageData(img, usePng) - while (stream.size() > maxDataSize) { - val ratio = sqrt(stream.size().toDouble() / maxDataSize.toDouble()) - val clippedRatio = min(ratio, 2.0) - val width = (img.width.toDouble() / clippedRatio).toInt() - val height = img.height * width / img.width - img = Bitmap.createScaledBitmap(img, width, height, true) - stream = compressImageData(img, usePng) - } - return stream -} - -private fun compressImageData(bitmap: Bitmap, usePng: Boolean): ByteArrayOutputStream { - val stream = ByteArrayOutputStream() - bitmap.compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream) - return stream -} val errorBitmapBytes = Base64.decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==", Base64.NO_WRAP) val errorBitmap: Bitmap = BitmapFactory.decodeByteArray(errorBitmapBytes, 0, errorBitmapBytes.size) -fun base64ToBitmap(base64ImageString: String): Bitmap { - val imageString = base64ImageString - .removePrefix("data:image/png;base64,") - .removePrefix("data:image/jpg;base64,") - try { - val imageBytes = Base64.decode(imageString, Base64.NO_WRAP) - return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) - } catch (e: Exception) { - Log.e(TAG, "base64ToBitmap error: $e") - return errorBitmap - } -} - class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract() { @CallSuper override fun createIntent(context: Context, input: Void?): Intent { @@ -269,3 +199,65 @@ fun GetImageBottomSheet( } } } + +class PickFromGallery: ActivityResultContract() { + override fun createIntent(context: Context, input: Int) = + Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply { + type = "image/*" + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data +} + +class PickMultipleImagesFromGallery: ActivityResultContract>() { + override fun createIntent(context: Context, input: Int) = + Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + type = "image/*" + } + + override fun parseResult(resultCode: Int, intent: Intent?): List = + if (intent?.data != null) + listOf(intent.data!!) + else if (intent?.clipData != null) + with(intent.clipData!!) { + val uris = ArrayList() + for (i in 0 until kotlin.math.min(itemCount, 10)) { + val uri = getItemAt(i).uri + if (uri != null) uris.add(uri) + } + if (itemCount > 10) { + AlertManager.shared.showAlertMsg(MR.strings.images_limit_title, MR.strings.images_limit_desc) + } + uris + } + else + emptyList() +} + + +class PickMultipleVideosFromGallery: ActivityResultContract>() { + override fun createIntent(context: Context, input: Int) = + Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + type = "video/*" + } + + override fun parseResult(resultCode: Int, intent: Intent?): List = + if (intent?.data != null) + listOf(intent.data!!) + else if (intent?.clipData != null) + with(intent.clipData!!) { + val uris = ArrayList() + for (i in 0 until kotlin.math.min(itemCount, 10)) { + val uri = getItemAt(i).uri + if (uri != null) uris.add(uri) + } + if (itemCount > 10) { + AlertManager.shared.showAlertMsg(MR.strings.videos_limit_title, MR.strings.videos_limit_desc) + } + uris + } + else + emptyList() +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt index 47efef1da3..3f7135309f 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.LinkPreview +import chat.simplex.app.platform.base64ToBitmap +import chat.simplex.app.platform.resizeImageToStrSize import chat.simplex.app.ui.theme.* import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.common.kt new file mode 100644 index 0000000000..3b1250a75e --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.common.kt @@ -0,0 +1,49 @@ +package chat.simplex.app.views.helpers + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import chat.simplex.app.SimplexApp +import chat.simplex.app.views.localauth.LocalAuthView +import chat.simplex.res.MR + +sealed class LAResult { + object Success: LAResult() + class Error(val errString: CharSequence): LAResult() + class Failed(val errString: CharSequence? = null): LAResult() + class Unavailable(val errString: CharSequence? = null): LAResult() +} + +data class LocalAuthRequest ( + val title: String?, + val reason: String, + val password: String, + val selfDestruct: Boolean, + val completed: (LAResult) -> Unit +) { + companion object { + val sample = LocalAuthRequest(generalGetString(MR.strings.la_enter_app_passcode), generalGetString(MR.strings.la_authenticate), "", selfDestruct = false) { } + } +} + +fun authenticateWithPasscode( + promptTitle: String, + promptSubtitle: String, + selfDestruct: Boolean, + completed: (LAResult) -> Unit) { + val password = DatabaseUtils.ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(MR.strings.la_no_app_password))) + ModalManager.shared.showPasscodeCustomModal { close -> + BackHandler { + close() + completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled))) + } + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && SimplexApp.context.chatModel.controller.appPrefs.selfDestruct.get()) { + close() + completed(it) + }) + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt index b6edae0e8f..6eed47b800 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt @@ -1,41 +1,14 @@ package chat.simplex.app.views.helpers import android.os.Build.VERSION.SDK_INT -import androidx.activity.compose.BackHandler import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.* import androidx.biometric.BiometricPrompt -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity -import chat.simplex.app.R import chat.simplex.app.SimplexApp -import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword -import chat.simplex.app.views.localauth.LocalAuthView +import chat.simplex.app.platform.mainActivity import chat.simplex.app.views.usersettings.LAMode -import chat.simplex.res.MR - -sealed class LAResult { - object Success: LAResult() - class Error(val errString: CharSequence): LAResult() - class Failed(val errString: CharSequence? = null): LAResult() - class Unavailable(val errString: CharSequence? = null): LAResult() -} - -data class LocalAuthRequest ( - val title: String?, - val reason: String, - val password: String, - val selfDestruct: Boolean, - val completed: (LAResult) -> Unit -) { - companion object { - val sample = LocalAuthRequest(generalGetString(MR.strings.la_enter_app_passcode), generalGetString(MR.strings.la_authenticate), "", selfDestruct = false) { } - } -} fun authenticate( promptTitle: String, @@ -44,7 +17,7 @@ fun authenticate( usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(), completed: (LAResult) -> Unit ) { - val activity = SimplexApp.context.mainActivity.get() ?: return completed(LAResult.Error("")) + val activity = mainActivity.get() ?: return completed(LAResult.Error("")) when (usingLAMode) { LAMode.SYSTEM -> when { SDK_INT in 28..29 -> @@ -55,19 +28,7 @@ fun authenticate( else -> completed(LAResult.Unavailable()) } LAMode.PASSCODE -> { - val password = ksAppPassword.get() ?: return completed(LAResult.Unavailable(generalGetString(MR.strings.la_no_app_password))) - ModalManager.shared.showPasscodeCustomModal { close -> - BackHandler { - close() - completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled))) - } - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - LocalAuthView(SimplexApp.context.chatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && SimplexApp.context.chatModel.controller.appPrefs.selfDestruct.get()) { - close() - completed(it) - }) - } - } + authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed) } } } @@ -119,30 +80,3 @@ private fun authenticateWithBiometricManager( else -> completed(LAResult.Unavailable()) } } - -fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.auth_simplex_lock_turned_on), - generalGetString(MR.strings.auth_you_will_be_required_to_authenticate_when_you_start_or_resume) -) - -fun laPasscodeNotSetAlert() = AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.lock_not_enabled), - generalGetString(MR.strings.you_can_turn_on_lock) -) - -fun laFailedAlert() { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.la_auth_failed), - text = generalGetString(MR.strings.la_could_not_be_verified) - ) -} - -fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.auth_unavailable), - generalGetString(MR.strings.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled) -) - -fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.auth_unavailable), - generalGetString(MR.strings.auth_device_authentication_is_disabled_turning_off) -) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt index 5b18ff5220..6b43cd603a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.work.* import chat.simplex.app.* import chat.simplex.app.SimplexService.Companion.showPassphraseNotification +import chat.simplex.app.model.ChatController import kotlinx.coroutines.* import java.util.Date import java.util.concurrent.TimeUnit @@ -55,7 +56,7 @@ class MessagesFetcherWork( var shouldReschedule = true try { withTimeout(durationSeconds * 1000L) { - val chatController = (applicationContext as SimplexApp).chatController + val chatController = ChatController SimplexService.waitDbMigrationEnds(chatController) val chatDbStatus = chatController.chatModel.chatDbStatus.value if (chatDbStatus != DBMigrationResult.OK) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt index 9076c8ecdc..f0cdfaec8b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt @@ -18,8 +18,8 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.* import chat.simplex.app.TAG -import chat.simplex.app.chatParseMarkdown import chat.simplex.app.model.* +import chat.simplex.app.platform.chatParseMarkdown import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.MarkdownText import com.google.accompanist.insets.navigationBarsWithImePadding diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Util.kt index a61729a5cb..a36bf42de6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -1,11 +1,7 @@ package chat.simplex.app.views.helpers -import android.app.Activity import android.app.Application //import android.app.LocaleManager -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.res.Configuration import android.content.res.Resources import android.graphics.* import android.graphics.Typeface @@ -17,15 +13,9 @@ import android.provider.OpenableColumns import android.text.Spanned import android.text.SpannedString import android.text.style.* -import android.util.Base64 import android.util.Log -import android.view.View -import android.view.ViewTreeObserver -import android.view.inputmethod.InputMethodManager import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.font.* @@ -33,77 +23,13 @@ import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.* import androidx.core.content.FileProvider -import androidx.core.graphics.ColorUtils import androidx.core.text.HtmlCompat import chat.simplex.app.* import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.ThemeOverrides -import com.charleskorn.kaml.decodeFromStream +import chat.simplex.app.platform.getLoadedFilePath import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.* -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import org.apache.commons.io.IOUtils import java.io.* -import java.text.SimpleDateFormat -import java.util.* -import kotlin.math.* - -fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action) - -fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job = - scope.launch { withContext(Dispatchers.Main, action) } - -fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job = - CoroutineScope(Dispatchers.Default).launch(block = action) - -enum class KeyboardState { - Opened, Closed -} - -@Composable -fun getKeyboardState(): State { - val keyboardState = remember { mutableStateOf(KeyboardState.Closed) } - val view = LocalView.current - DisposableEffect(view) { - val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - keyboardState.value = if (keypadHeight > screenHeight * 0.15) { - KeyboardState.Opened - } else { - KeyboardState.Closed - } - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - - onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) - } - } - - return keyboardState -} - -fun hideKeyboard(view: View) = - (SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0) - -// Resource to annotated string from -// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources -fun generalGetString(id: StringResource): String { - // prefer stringResource in Composable items to retain preview abilities - return id.getString(SimplexApp.context) -} - -@Composable -@ReadOnlyComposable -private fun resources(): Resources { - LocalConfiguration.current - return LocalContext.current.resources -} fun Spanned.toHtmlWithoutParagraphs(): String { return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) @@ -237,48 +163,6 @@ private fun spannableStringToAnnotatedString( } } -// maximum image file size to be auto-accepted -const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB -const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 -const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 -const val MAX_VIDEO_SIZE_AUTO_RCV: Long = 1_047_552 // 1023KB - -const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 300_000 - -const val MAX_FILE_SIZE_SMP: Long = 8000000 - -const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB - -fun getFilesDirectory(): String { - return SimplexApp.context.filesDir.toString() -} - -fun getTempFilesDirectory(): String { - return "${getFilesDirectory()}/temp_files" -} - -fun getAppFilesDirectory(): String { - return "${getFilesDirectory()}/app_files" -} - -fun getAppFilePath(fileName: String): String { - return "${getAppFilesDirectory()}/$fileName" -} - -fun getAppFileUri(fileName: String): Uri { - return Uri.parse("${getAppFilesDirectory()}/$fileName") -} - - -fun getLoadedFilePath(file: CIFile?): String? { - return if (file?.filePath != null && file.loaded) { - val filePath = getAppFilePath(file.filePath) - if (File(filePath).exists()) filePath else null - } else { - null - } -} - // https://developer.android.com/training/data-storage/shared/documents-files#bitmap fun getLoadedImage(file: CIFile?): Bitmap? { val filePath = getLoadedFilePath(file) @@ -343,7 +227,7 @@ fun getAppFilePath(uri: Uri): String? { return SimplexApp.context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) cursor.moveToFirst() - getAppFilePath(cursor.getString(nameIndex)) + chat.simplex.app.platform.getAppFilePath(cursor.getString(nameIndex)) } } @@ -395,69 +279,6 @@ fun getDrawableFromUri(uri: Uri, withAlertOnException: Boolean = true): Drawable } } -fun getThemeFromUri(uri: Uri, withAlertOnException: Boolean = true): ThemeOverrides? { - SimplexApp.context.contentResolver.openInputStream(uri).use { - runCatching { - return yaml.decodeFromStream(it!!) - }.onFailure { - if (withAlertOnException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.import_theme_error), - text = generalGetString(MR.strings.import_theme_error_desc), - ) - } - } - } - return null -} - -fun saveImage(uri: Uri): String? { - val bitmap = getBitmapFromUri(uri) ?: return null - return saveImage(bitmap) -} - -fun saveImage(image: Bitmap): String? { - return try { - val ext = if (image.hasAlpha()) "png" else "jpg" - val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val fileToSave = generateNewFileName("IMG", ext) - val file = File(getAppFilePath(fileToSave)) - val output = FileOutputStream(file) - dataResized.writeTo(output) - output.flush() - output.close() - fileToSave - } catch (e: Exception) { - Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}") - null - } -} - -fun saveAnimImage(uri: Uri): String? { - return try { - val filename = getFileName(uri)?.lowercase() - var ext = when { - // remove everything but extension - filename?.contains(".") == true -> filename.replaceBeforeLast('.', "").replace(".", "") - else -> "gif" - } - // Just in case the image has a strange extension - if (ext.length < 3 || ext.length > 4) ext = "gif" - val fileToSave = generateNewFileName("IMG", ext) - val file = File(getAppFilePath(fileToSave)) - val output = FileOutputStream(file) - SimplexApp.context.contentResolver.openInputStream(uri)!!.use { input -> - output.use { output -> - input.copyTo(output) - } - } - fileToSave - } catch (e: Exception) { - Log.e(chat.simplex.app.TAG, "Util.kt saveAnimImage error: ${e.message}") - null - } -} - fun saveTempImageUncompressed(image: Bitmap, asPng: Boolean): File? { return try { val ext = if (asPng) "png" else "jpg" @@ -476,103 +297,6 @@ fun saveTempImageUncompressed(image: Bitmap, asPng: Boolean): File? { } } -fun saveFileFromUri(uri: Uri): String? { - return try { - val inputStream = SimplexApp.context.contentResolver.openInputStream(uri) - val fileToSave = getFileName(uri) - if (inputStream != null && fileToSave != null) { - val destFileName = uniqueCombine(fileToSave) - val destFile = File(getAppFilePath(destFileName)) - IOUtils.copy(inputStream, FileOutputStream(destFile)) - destFileName - } else { - Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream") - null - } - } catch (e: Exception) { - Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}") - null - } -} - -fun generateNewFileName(prefix: String, ext: String): String { - val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) - sdf.timeZone = TimeZone.getTimeZone("GMT") - val timestamp = sdf.format(Date()) - return uniqueCombine("${prefix}_$timestamp.$ext") -} - -fun uniqueCombine(fileName: String): String { - val orig = File(fileName) - val name = orig.nameWithoutExtension - val ext = orig.extension - fun tryCombine(n: Int): String { - val suffix = if (n == 0) "" else "_$n" - val f = "$name$suffix.$ext" - return if (File(getAppFilePath(f)).exists()) tryCombine(n + 1) else f - } - return tryCombine(0) -} - -fun formatBytes(bytes: Long): String { - if (bytes == 0.toLong()) { - return "0 bytes" - } - val bytesDouble = bytes.toDouble() - val k = 1024.toDouble() - val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") - val i = floor(log2(bytesDouble) / log2(k)) - val size = bytesDouble / k.pow(i) - val unit = units[i.toInt()] - - return if (i <= 1) { - String.format("%.0f %s", size, unit) - } else { - String.format("%.2f %s", size, unit) - } -} - -fun removeFile(fileName: String): Boolean { - val file = File(getAppFilePath(fileName)) - val fileDeleted = file.delete() - if (!fileDeleted) { - Log.e(chat.simplex.app.TAG, "Util.kt removeFile error") - } - return fileDeleted -} - -fun deleteAppFiles() { - val dir = File(getAppFilesDirectory()) - try { - dir.list()?.forEach { - removeFile(it) - } - } catch (e: java.lang.Exception) { - Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}") - } -} - -fun directoryFileCountAndSize(dir: String): Pair { // count, size in bytes - var fileCount = 0 - var bytes = 0L - try { - File(dir).listFiles()?.forEach { - fileCount++ - bytes += it.length() - } - } catch (e: java.lang.Exception) { - Log.e(TAG, "Util directoryFileCountAndSize error: ${e.stackTraceToString()}") - } - return fileCount to bytes -} - -fun getMaxFileSize(fileProtocol: FileProtocol): Long { - return when (fileProtocol) { - FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP - FileProtocol.SMP -> MAX_FILE_SIZE_SMP - } -} - fun getBitmapFromVideo(uri: Uri, timestamp: Long? = null, random: Boolean = true): VideoPlayer.PreviewAndDuration { val mmr = MediaMetadataRetriever() mmr.setDataSource(SimplexApp.context, uri) @@ -585,104 +309,3 @@ fun getBitmapFromVideo(uri: Uri, timestamp: Long? = null, random: Boolean = true mmr.release() return VideoPlayer.PreviewAndDuration(image, durationMs, timestamp ?: 0) } - -fun Color.darker(factor: Float = 0.1f): Color = - Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha) - -fun Color.lighter(factor: Float = 0.1f): Color = - Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha) - -fun Color.mixWith(color: Color, alpha: Float): Color = - Color(ColorUtils.blendARGB(color.toArgb(), toArgb(), alpha)) - -fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) - -fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT) - -val LongRange.Companion.saver - get() = Saver, Pair>( - save = { it.value.first to it.value.last }, - restore = { mutableStateOf(it.first..it.second) } - ) - -/* Make sure that T class has @Serializable annotation */ -inline fun serializableSaver(): Saver = Saver( - save = { json.encodeToString(it) }, - restore = { json.decodeFromString(it) } - ) - -fun saveAppLocale(pref: SharedPreference, activity: Activity, languageCode: String? = null) { -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// val localeManager = SimplexApp.context.getSystemService(LocaleManager::class.java) -// localeManager.applicationLocales = LocaleList(Locale.forLanguageTag(languageCode ?: return)) -// } else { - pref.set(languageCode) - if (languageCode == null) { - activity.applyLocale(SimplexApp.context.defaultLocale) - } - activity.recreate() -// } -} - -fun Activity.applyAppLocale(pref: SharedPreference) { -// if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - val lang = pref.get() - if (lang == null || lang == Locale.getDefault().language) return - applyLocale(Locale.forLanguageTag(lang)) -// } -} - -private fun Activity.applyLocale(locale: Locale) { - Locale.setDefault(locale) - val appConf = Configuration(SimplexApp.context.resources.configuration).apply { setLocale(locale) } - val activityConf = Configuration(resources.configuration).apply { setLocale(locale) } - @Suppress("DEPRECATION") - SimplexApp.context.resources.updateConfiguration(appConf, resources.displayMetrics) - @Suppress("DEPRECATION") - resources.updateConfiguration(activityConf, resources.displayMetrics) -} - -fun UriHandler.openUriCatching(uri: String) { - try { - openUri(uri) - } catch (e: ActivityNotFoundException) { - Log.e(TAG, e.stackTraceToString()) - } -} - -fun IntSize.Companion.Saver(): Saver = Saver( - save = { it.width to it.height }, - restore = { IntSize(it.first, it.second) } -) - -@Composable -fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) { - val context = LocalContext.current - DisposableEffect(Unit) { - always() - val activity = context as? Activity ?: return@DisposableEffect onDispose {} - val orientation = activity.resources.configuration.orientation - onDispose { - whenDispose() - if (orientation == activity.resources.configuration.orientation) { - whenGone() - } - } - } -} - -@Composable -fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) { - val context = LocalContext.current - DisposableEffect(Unit) { - always() - val activity = context as? Activity ?: return@DisposableEffect onDispose {} - val orientation = activity.resources.configuration.orientation - onDispose { - whenDispose() - if (orientation != activity.resources.configuration.orientation) { - whenRotate() - } - } - } -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Utils.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Utils.common.kt new file mode 100644 index 0000000000..1f04855cbd --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Utils.common.kt @@ -0,0 +1,296 @@ +package chat.simplex.app.views.helpers + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.graphics.Bitmap +import android.net.Uri +import android.util.Base64 +import android.util.Log +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.unit.IntSize +import androidx.core.graphics.ColorUtils +import chat.simplex.app.* +import chat.simplex.app.model.* +import chat.simplex.app.platform.getAppFilesDirectory +import chat.simplex.app.platform.resizeImageToDataSize +import chat.simplex.app.ui.theme.ThemeOverrides +import chat.simplex.res.MR +import com.charleskorn.kaml.decodeFromStream +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.apache.commons.io.IOUtils +import java.io.* +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.* + +fun withApi(action: suspend CoroutineScope.() -> Unit): Job = withScope(GlobalScope, action) + +fun withScope(scope: CoroutineScope, action: suspend CoroutineScope.() -> Unit): Job = + scope.launch { withContext(Dispatchers.Main, action) } + +fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job = + CoroutineScope(Dispatchers.Default).launch(block = action) + +enum class KeyboardState { + Opened, Closed +} + +fun generalGetString(id: StringResource): String { + // prefer stringResource in Composable items to retain preview abilities + return id.getString(SimplexApp.context) +} + +// maximum image file size to be auto-accepted +const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB +const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 +const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 +const val MAX_VIDEO_SIZE_AUTO_RCV: Long = 1_047_552 // 1023KB + +const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 300_000 + +const val MAX_FILE_SIZE_SMP: Long = 8000000 + +const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB + +fun getAppFileUri(fileName: String): Uri { + return Uri.parse("${getAppFilesDirectory()}/$fileName") +} + +fun getThemeFromUri(uri: Uri, withAlertOnException: Boolean = true): ThemeOverrides? { + SimplexApp.context.contentResolver.openInputStream(uri).use { + runCatching { + return yaml.decodeFromStream(it!!) + }.onFailure { + if (withAlertOnException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.import_theme_error), + text = generalGetString(MR.strings.import_theme_error_desc), + ) + } + } + } + return null +} + +fun saveImage(uri: Uri): String? { + val bitmap = getBitmapFromUri(uri) ?: return null + return saveImage(bitmap) +} + +fun saveImage(image: Bitmap): String? { + return try { + val ext = if (image.hasAlpha()) "png" else "jpg" + val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) + val fileToSave = generateNewFileName("IMG", ext) + val file = File(chat.simplex.app.platform.getAppFilePath(fileToSave)) + val output = FileOutputStream(file) + dataResized.writeTo(output) + output.flush() + output.close() + fileToSave + } catch (e: Exception) { + Log.e(TAG, "Util.kt saveImage error: ${e.message}") + null + } +} + +fun saveAnimImage(uri: Uri): String? { + return try { + val filename = getFileName(uri)?.lowercase() + var ext = when { + // remove everything but extension + filename?.contains(".") == true -> filename.replaceBeforeLast('.', "").replace(".", "") + else -> "gif" + } + // Just in case the image has a strange extension + if (ext.length < 3 || ext.length > 4) ext = "gif" + val fileToSave = generateNewFileName("IMG", ext) + val file = File(chat.simplex.app.platform.getAppFilePath(fileToSave)) + val output = FileOutputStream(file) + SimplexApp.context.contentResolver.openInputStream(uri)!!.use { input -> + output.use { output -> + input.copyTo(output) + } + } + fileToSave + } catch (e: Exception) { + Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}") + null + } +} + + +fun saveFileFromUri(uri: Uri): String? { + return try { + val inputStream = SimplexApp.context.contentResolver.openInputStream(uri) + val fileToSave = getFileName(uri) + if (inputStream != null && fileToSave != null) { + val destFileName = uniqueCombine(fileToSave) + val destFile = File(chat.simplex.app.platform.getAppFilePath(destFileName)) + IOUtils.copy(inputStream, FileOutputStream(destFile)) + destFileName + } else { + Log.e(TAG, "Util.kt saveFileFromUri null inputStream") + null + } + } catch (e: Exception) { + Log.e(TAG, "Util.kt saveFileFromUri error: ${e.message}") + null + } +} + +fun generateNewFileName(prefix: String, ext: String): String { + val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("GMT") + val timestamp = sdf.format(Date()) + return uniqueCombine("${prefix}_$timestamp.$ext") +} + +fun uniqueCombine(fileName: String): String { + val orig = File(fileName) + val name = orig.nameWithoutExtension + val ext = orig.extension + fun tryCombine(n: Int): String { + val suffix = if (n == 0) "" else "_$n" + val f = "$name$suffix.$ext" + return if (File(chat.simplex.app.platform.getAppFilePath(f)).exists()) tryCombine(n + 1) else f + } + return tryCombine(0) +} + +fun formatBytes(bytes: Long): String { + if (bytes == 0.toLong()) { + return "0 bytes" + } + val bytesDouble = bytes.toDouble() + val k = 1024.toDouble() + val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + val i = floor(log2(bytesDouble) / log2(k)) + val size = bytesDouble / k.pow(i) + val unit = units[i.toInt()] + + return if (i <= 1) { + String.format("%.0f %s", size, unit) + } else { + String.format("%.2f %s", size, unit) + } +} + +fun removeFile(fileName: String): Boolean { + val file = File(chat.simplex.app.platform.getAppFilePath(fileName)) + val fileDeleted = file.delete() + if (!fileDeleted) { + Log.e(TAG, "Util.kt removeFile error") + } + return fileDeleted +} + +fun deleteAppFiles() { + val dir = File(getAppFilesDirectory()) + try { + dir.list()?.forEach { + removeFile(it) + } + } catch (e: java.lang.Exception) { + Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}") + } +} + +fun directoryFileCountAndSize(dir: String): Pair { // count, size in bytes + var fileCount = 0 + var bytes = 0L + try { + File(dir).listFiles()?.forEach { + fileCount++ + bytes += it.length() + } + } catch (e: java.lang.Exception) { + Log.e(TAG, "Util directoryFileCountAndSize error: ${e.stackTraceToString()}") + } + return fileCount to bytes +} + +fun getMaxFileSize(fileProtocol: FileProtocol): Long { + return when (fileProtocol) { + FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP + FileProtocol.SMP -> MAX_FILE_SIZE_SMP + } +} + +fun Color.darker(factor: Float = 0.1f): Color = + Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha) + +fun Color.lighter(factor: Float = 0.1f): Color = + Color(min(red * (1 + factor), 1f), min(green * (1 + factor), 1f), min(blue * (1 + factor), 1f), alpha) + +fun Color.mixWith(color: Color, alpha: Float): Color = + Color(ColorUtils.blendARGB(color.toArgb(), toArgb(), alpha)) + +fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) + +fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT) + +val LongRange.Companion.saver + get() = Saver, Pair>( + save = { it.value.first to it.value.last }, + restore = { mutableStateOf(it.first..it.second) } + ) + +/* Make sure that T class has @Serializable annotation */ +inline fun serializableSaver(): Saver = Saver( + save = { json.encodeToString(it) }, + restore = { json.decodeFromString(it) } +) + +fun UriHandler.openUriCatching(uri: String) { + try { + openUri(uri) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, e.stackTraceToString()) + } +} + +fun IntSize.Companion.Saver(): Saver = Saver( + save = { it.width to it.height }, + restore = { IntSize(it.first, it.second) } +) + +@Composable +fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) { + val context = LocalContext.current + DisposableEffect(Unit) { + always() + val activity = context as? Activity ?: return@DisposableEffect onDispose {} + val orientation = activity.resources.configuration.orientation + onDispose { + whenDispose() + if (orientation == activity.resources.configuration.orientation) { + whenGone() + } + } + } +} + +@Composable +fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) { + val context = LocalContext.current + DisposableEffect(Unit) { + always() + val activity = context as? Activity ?: return@DisposableEffect onDispose {} + val orientation = activity.resources.configuration.orientation + onDispose { + whenDispose() + if (orientation != activity.resources.configuration.orientation) { + whenRotate() + } + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt index 9187c85d9b..f3281b38e0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import dev.icerock.moko.resources.compose.stringResource import chat.simplex.app.* import chat.simplex.app.model.* +import chat.simplex.app.platform.initChatController import chat.simplex.app.views.database.deleteChatAsync import chat.simplex.app.views.database.stopChatAsync import chat.simplex.app.views.helpers.* @@ -52,7 +53,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( m.chatDbChanged.value = true m.chatDbStatus.value = null try { - SimplexApp.context.initChatController(startChat = true) + initChatController(startChat = true) } catch (e: Exception) { Log.d(TAG, "initializeChat ${e.stackTraceToString()}") } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index 39bd6cd333..fb987be6bb 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -16,7 +16,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import chat.simplex.app.R +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.usersettings.SettingsActionItem diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt index 2609098ebd..7b54b7ba2e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.cropToSquare +import chat.simplex.app.platform.resizeImageToStrSize import chat.simplex.app.ui.theme.* import chat.simplex.app.views.ProfileNameField import chat.simplex.app.views.chat.group.AddGroupMembersView diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ContactConnectionInfoView.kt index 3317472cc5..95501c2a88 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ContactConnectionInfoView.kt @@ -14,8 +14,8 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.tooling.preview.Preview -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.LocalAliasEditor import chat.simplex.app.views.chatlist.deleteContactConnectionAlert diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.common.kt new file mode 100644 index 0000000000..0d48a68abe --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.common.kt @@ -0,0 +1,80 @@ +package chat.simplex.app.views.newchat + +import android.graphics.Bitmap +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.* +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.graphics.* +import boofcv.alg.drawing.FiducialImageEngine +import boofcv.alg.fiducial.qrcode.* +import boofcv.android.ConvertBitmap +import chat.simplex.app.platform.addLogo +import chat.simplex.app.platform.shareFile +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.launch + +@Composable +fun QRCode( + connReq: String, + modifier: Modifier = Modifier, + tintColor: Color = Color(0xff062d56), + withLogo: Boolean = true +) { + val scope = rememberCoroutineScope() + + BoxWithConstraints { + val maxWidthInPx = with(LocalDensity.current) { maxWidth.roundToPx() } + val qr = remember(maxWidthInPx, connReq, tintColor, withLogo) { + qrCodeBitmap(connReq, maxWidthInPx).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) + .let { if (withLogo) it.addLogo() else it } + .asImageBitmap() + } + Image( + bitmap = qr, + contentDescription = stringResource(MR.strings.image_descr_qr_code), + modifier + .clickable { + scope.launch { + val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) + .let { if (withLogo) it.addLogo() else it } + val file = saveTempImageUncompressed(image, false) + if (file != null) { + shareFile("", file.absolutePath) + } + } + } + ) + } +} + +fun qrCodeBitmap(content: String, size: Int = 1024): Bitmap { + val qrCode = QrCodeEncoder().addAutomatic(content).setError(QrCode.ErrorLevel.L).fixate() + /** See [QrCodeGeneratorImage.initialize] and [FiducialImageEngine.configure] for size calculation */ + val numModules = QrCode.totalModules(qrCode.version) + val borderModule = 1 + // val calculatedFinalWidth = (pixelsPerModule * numModules) + 2 * (borderModule * pixelsPerModule) + // size = (x * numModules) + 2 * (borderModule * x) + // size / x = numModules + 2 * borderModule + // x = size / (numModules + 2 * borderModule) + val pixelsPerModule = size / (numModules + 2 * borderModule) + // + 1 to make it with better quality + val renderer = QrCodeGeneratorImage(pixelsPerModule + 1) + renderer.borderModule = borderModule + renderer.render(qrCode) + return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565).scale(size, size) +} + +@Preview +@Composable +fun PreviewQRCode() { + SimpleXTheme { + QRCode(connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D") + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.kt index c3830bd91b..7081acf028 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.kt @@ -1,76 +1,6 @@ package chat.simplex.app.views.newchat import android.graphics.Bitmap -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.* -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.core.graphics.* -import androidx.core.graphics.drawable.toBitmap -import boofcv.alg.drawing.FiducialImageEngine -import boofcv.alg.fiducial.qrcode.* -import boofcv.android.ConvertBitmap -import chat.simplex.app.R -import chat.simplex.app.SimplexApp -import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.* -import chat.simplex.res.MR -import kotlinx.coroutines.launch - -@Composable -fun QRCode( - connReq: String, - modifier: Modifier = Modifier, - tintColor: Color = Color(0xff062d56), - withLogo: Boolean = true -) { - val scope = rememberCoroutineScope() - - BoxWithConstraints { - val maxWidthInPx = with(LocalDensity.current) { maxWidth.roundToPx() } - val qr = remember(maxWidthInPx, connReq, tintColor, withLogo) { - qrCodeBitmap(connReq, maxWidthInPx).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) - .let { if (withLogo) it.addLogo() else it } - .asImageBitmap() - } - Image( - bitmap = qr, - contentDescription = stringResource(MR.strings.image_descr_qr_code), - modifier - .clickable { - scope.launch { - val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) - .let { if (withLogo) it.addLogo() else it } - val file = saveTempImageUncompressed(image, false) - if (file != null) { - shareFile("", file.absolutePath) - } - } - } - ) - } -} - -fun qrCodeBitmap(content: String, size: Int = 1024): Bitmap { - val qrCode = QrCodeEncoder().addAutomatic(content).setError(QrCode.ErrorLevel.L).fixate() - /** See [QrCodeGeneratorImage.initialize] and [FiducialImageEngine.configure] for size calculation */ - val numModules = QrCode.totalModules(qrCode.version) - val borderModule = 1 - // val calculatedFinalWidth = (pixelsPerModule * numModules) + 2 * (borderModule * pixelsPerModule) - // size = (x * numModules) + 2 * (borderModule * x) - // size / x = numModules + 2 * borderModule - // x = size / (numModules + 2 * borderModule) - val pixelsPerModule = size / (numModules + 2 * borderModule) - // + 1 to make it with better quality - val renderer = QrCodeGeneratorImage(pixelsPerModule + 1) - renderer.borderModule = borderModule - renderer.render(qrCode) - return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565).scale(size, size) -} fun Bitmap.replaceColor(from: Int, to: Int): Bitmap { val pixels = IntArray(width * height) @@ -85,22 +15,3 @@ fun Bitmap.replaceColor(from: Int, to: Int): Bitmap { setPixels(pixels, 0, width, 0, 0, width, height) return this } - -fun Bitmap.addLogo(): Bitmap = applyCanvas { - val radius = (width * 0.16f) / 2 - val paint = android.graphics.Paint() - paint.color = android.graphics.Color.WHITE - drawCircle(width / 2f, height / 2f, radius, paint) - val logo = SimplexApp.context.resources.getDrawable(R.mipmap.icon_foreground, null).toBitmap() - val logoSize = (width * 0.24).toInt() - translate((width - logoSize) / 2f, (height - logoSize) / 2f) - drawBitmap(logo, null, android.graphics.Rect(0, 0, logoSize, logoSize), null) -} - -@Preview -@Composable -fun PreviewQRCode() { - SimpleXTheme { - QRCode(connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D") - } -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.common.kt new file mode 100644 index 0000000000..9c37a34207 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.common.kt @@ -0,0 +1,151 @@ +package chat.simplex.app.views.newchat + +import SectionBottomSpacer +import android.content.res.Configuration +import android.net.Uri +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.json +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Composable +fun QRCodeScanner(close: () -> Unit) { + QRCodeScanner { connReqUri -> + try { + val uri = Uri.parse(connReqUri) + withUriAction(uri) { linkType -> + val action = suspend { + Log.d(TAG, "connectViaUri: connecting") + if (connectViaUri(ChatModel, linkType, uri)) { + close() + } + } + if (linkType == ConnectionLinkType.GROUP) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_via_group_link), + text = generalGetString(MR.strings.you_will_join_group), + confirmText = generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { action() } } + ) + } else action() + } + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_QR_code), + text = generalGetString(MR.strings.this_QR_code_is_not_a_link) + ) + } + } +} + +enum class ConnectionLinkType { + CONTACT, INVITATION, GROUP +} + +@Serializable +sealed class CReqClientData { + @Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData() +} + +fun withUriAction(uri: Uri, run: suspend (ConnectionLinkType) -> Unit) { + val action = uri.path?.drop(1)?.replace("/", "") + val data = uri.toString().replaceFirst("#/", "/").toUri().getQueryParameter("data") + val type = when { + data != null -> { + val parsed = runCatching { + json.decodeFromString(CReqClientData.serializer(), data) + } + when { + parsed.getOrNull() is CReqClientData.Group -> ConnectionLinkType.GROUP + else -> null + } + } + action == "contact" -> ConnectionLinkType.CONTACT + action == "invitation" -> ConnectionLinkType.INVITATION + else -> null + } + if (type != null) { + withApi { run(type) } + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_contact_link), + text = generalGetString(MR.strings.this_link_is_not_a_valid_connection_link) + ) + } +} + +suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: Uri): Boolean { + val r = chatModel.controller.apiConnect(uri.toString()) + if (r) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.connection_request_sent), + text = + when (action) { + ConnectionLinkType.CONTACT -> generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) + ConnectionLinkType.INVITATION -> generalGetString(MR.strings.you_will_be_connected_when_your_contacts_device_is_online) + ConnectionLinkType.GROUP -> generalGetString(MR.strings.you_will_be_connected_when_group_host_device_is_online) + } + ) + } + return r +} + +@Composable +fun ConnectContactLayout(chatModelIncognito: Boolean, close: () -> Unit) { + Column( + Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + AppBarTitle(stringResource(MR.strings.scan_QR_code), false) + InfoAboutIncognito( + chatModelIncognito, + true, + generalGetString(MR.strings.incognito_random_profile_description), + generalGetString(MR.strings.your_profile_will_be_sent) + ) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(bottom = 12.dp) + ) { QRCodeScanner(close) } + Text( + annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link), + lineHeight = 22.sp + ) + SectionBottomSpacer() + } +} + +@Preview +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewConnectContactLayout() { + SimpleXTheme { + ConnectContactLayout( + chatModelIncognito = false, + close = {} + ) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt index 2bbef58cf8..dd159a4c91 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt @@ -1,33 +1,10 @@ package chat.simplex.app.views.newchat -import SectionBottomSpacer import android.Manifest -import android.content.res.Configuration -import android.net.Uri -import android.util.Log -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import chat.simplex.app.R -import chat.simplex.app.TAG import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.json -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.* import com.google.accompanist.permissions.rememberPermissionState -import chat.simplex.res.MR -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable @Composable fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { @@ -37,128 +14,6 @@ fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { } ConnectContactLayout( chatModelIncognito = chatModel.incognito.value, - qrCodeScanner = { - QRCodeScanner { connReqUri -> - try { - val uri = Uri.parse(connReqUri) - withUriAction(uri) { linkType -> - val action = suspend { - Log.d(TAG, "connectViaUri: connecting") - if (connectViaUri(chatModel, linkType, uri)) { - close() - } - } - if (linkType == ConnectionLinkType.GROUP) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), - confirmText = generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { action() } } - ) - } else action() - } - } catch (e: RuntimeException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_QR_code), - text = generalGetString(MR.strings.this_QR_code_is_not_a_link) - ) - } - } - }, + close ) } - -enum class ConnectionLinkType { - CONTACT, INVITATION, GROUP -} - -@Serializable -sealed class CReqClientData { - @Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData() -} - -fun withUriAction(uri: Uri, run: suspend (ConnectionLinkType) -> Unit) { - val action = uri.path?.drop(1)?.replace("/", "") - val data = uri.toString().replaceFirst("#/", "/").toUri().getQueryParameter("data") - val type = when { - data != null -> { - val parsed = runCatching { - json.decodeFromString(CReqClientData.serializer(), data) - } - when { - parsed.getOrNull() is CReqClientData.Group -> ConnectionLinkType.GROUP - else -> null - } - } - action == "contact" -> ConnectionLinkType.CONTACT - action == "invitation" -> ConnectionLinkType.INVITATION - else -> null - } - if (type != null) { - withApi { run(type) } - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_contact_link), - text = generalGetString(MR.strings.this_link_is_not_a_valid_connection_link) - ) - } -} - -suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: Uri): Boolean { - val r = chatModel.controller.apiConnect(uri.toString()) - if (r) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.connection_request_sent), - text = - when (action) { - ConnectionLinkType.CONTACT -> generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) - ConnectionLinkType.INVITATION -> generalGetString(MR.strings.you_will_be_connected_when_your_contacts_device_is_online) - ConnectionLinkType.GROUP -> generalGetString(MR.strings.you_will_be_connected_when_group_host_device_is_online) - } - ) - } - return r -} - -@Composable -fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit) { - Column( - Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - AppBarTitle(stringResource(MR.strings.scan_QR_code), false) - InfoAboutIncognito( - chatModelIncognito, - true, - generalGetString(MR.strings.incognito_random_profile_description), - generalGetString(MR.strings.your_profile_will_be_sent) - ) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 1F) - .padding(bottom = 12.dp) - ) { qrCodeScanner() } - Text( - annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link), - lineHeight = 22.sp - ) - SectionBottomSpacer() - } -} - -@Preview -@Preview( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -) -@Composable -fun PreviewConnectContactLayout() { - SimpleXTheme { - ConnectContactLayout( - chatModelIncognito = false, - qrCodeScanner = { Surface {} }, - ) - } -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/CreateSimpleXAddress.kt index af8b93c0f3..6c632160dc 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/CreateSimpleXAddress.kt @@ -14,9 +14,10 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.app.* -import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.model.UserContactLinkRec +import chat.simplex.app.platform.sendEmail +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.QRCode diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt index 997d9edb4b..ac37eff8d8 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chat.simplex.app.model.ChatModel import chat.simplex.app.views.CreateProfilePanel -import chat.simplex.app.views.helpers.getKeyboardState +import chat.simplex.app.platform.getKeyboardState import com.google.accompanist.insets.ProvideWindowInsets import kotlinx.coroutines.launch diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.common.kt new file mode 100644 index 0000000000..b76629952f --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.common.kt @@ -0,0 +1,78 @@ +package chat.simplex.app.views.onboarding + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.changeNotificationsMode +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource + +@Composable +fun SetNotificationsMode(m: ChatModel) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 14.dp) + ) { + //CloseSheetBar(null) + AppBarTitle(stringResource(MR.strings.onboarding_notifications_mode_title)) + val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) } + Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) { + Text(stringResource(MR.strings.onboarding_notifications_mode_subtitle), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + Spacer(Modifier.height(DEFAULT_PADDING * 2f)) + NotificationButton(currentMode, NotificationsMode.OFF, MR.strings.onboarding_notifications_mode_off, MR.strings.onboarding_notifications_mode_off_desc) + NotificationButton(currentMode, NotificationsMode.PERIODIC, MR.strings.onboarding_notifications_mode_periodic, MR.strings.onboarding_notifications_mode_periodic_desc) + NotificationButton(currentMode, NotificationsMode.SERVICE, MR.strings.onboarding_notifications_mode_service, MR.strings.onboarding_notifications_mode_service_desc) + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) { + OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) { + changeNotificationsMode(currentMode.value, m) + } + } + Spacer(Modifier.fillMaxHeight().weight(1f)) + } + SetNotificationsModeAdditions() +} + +@Composable +private fun NotificationButton(currentMode: MutableState, mode: NotificationsMode, title: StringResource, description: StringResource) { + TextButton( + onClick = { currentMode.value = mode }, + border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)), + shape = RoundedCornerShape(35.dp), + ) { + Column(Modifier.padding(horizontal = 10.dp).padding(top = 4.dp, bottom = 8.dp)) { + Text( + stringResource(title), + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Medium, + color = if (currentMode.value == mode) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier.padding(bottom = 8.dp).align(Alignment.CenterHorizontally), + textAlign = TextAlign.Center + ) + Text(annotatedStringResource(description), + Modifier.align(Alignment.CenterHorizontally), + fontSize = 15.sp, + color = MaterialTheme.colors.onBackground, + lineHeight = 24.sp, + textAlign = TextAlign.Center + ) + } + } + Spacer(Modifier.height(14.dp)) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.kt index 9f3b87194e..0d8ac2ef1c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.kt @@ -2,59 +2,10 @@ package chat.simplex.app.views.onboarding import android.Manifest import android.os.Build -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import chat.simplex.app.R +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import chat.simplex.app.SimplexApp -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.NtfManager -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.NotificationsMode -import chat.simplex.app.views.usersettings.changeNotificationsMode import com.google.accompanist.permissions.rememberPermissionState -import chat.simplex.res.MR -import dev.icerock.moko.resources.StringResource - -@Composable -fun SetNotificationsMode(m: ChatModel) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(vertical = 14.dp) - ) { - //CloseSheetBar(null) - AppBarTitle(stringResource(MR.strings.onboarding_notifications_mode_title)) - val currentMode = rememberSaveable { mutableStateOf(NotificationsMode.default) } - Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) { - Text(stringResource(MR.strings.onboarding_notifications_mode_subtitle), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) - Spacer(Modifier.height(DEFAULT_PADDING * 2f)) - NotificationButton(currentMode, NotificationsMode.OFF, MR.strings.onboarding_notifications_mode_off, MR.strings.onboarding_notifications_mode_off_desc) - NotificationButton(currentMode, NotificationsMode.PERIODIC, MR.strings.onboarding_notifications_mode_periodic, MR.strings.onboarding_notifications_mode_periodic_desc) - NotificationButton(currentMode, NotificationsMode.SERVICE, MR.strings.onboarding_notifications_mode_service, MR.strings.onboarding_notifications_mode_service_desc) - } - Spacer(Modifier.fillMaxHeight().weight(1f)) - Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), contentAlignment = Alignment.Center) { - OnboardingActionButton(MR.strings.use_chat, OnboardingStage.OnboardingComplete, m.onboardingStage, false) { - changeNotificationsMode(currentMode.value, m) - } - } - Spacer(Modifier.fillMaxHeight().weight(1f)) - } - SetNotificationsModeAdditions() -} @Composable fun SetNotificationsModeAdditions() { @@ -73,31 +24,3 @@ fun SetNotificationsModeAdditions() { } } } - -@Composable -private fun NotificationButton(currentMode: MutableState, mode: NotificationsMode, title: StringResource, description: StringResource) { - TextButton( - onClick = { currentMode.value = mode }, - border = BorderStroke(1.dp, color = if (currentMode.value == mode) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)), - shape = RoundedCornerShape(35.dp), - ) { - Column(Modifier.padding(horizontal = 10.dp).padding(top = 4.dp, bottom = 8.dp)) { - Text( - stringResource(title), - style = MaterialTheme.typography.h3, - fontWeight = FontWeight.Medium, - color = if (currentMode.value == mode) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier.padding(bottom = 8.dp).align(Alignment.CenterHorizontally), - textAlign = TextAlign.Center - ) - Text(annotatedStringResource(description), - Modifier.align(Alignment.CenterHorizontally), - fontSize = 15.sp, - color = MaterialTheme.colors.onBackground, - lineHeight = 24.sp, - textAlign = TextAlign.Center - ) - } - } - Spacer(Modifier.height(14.dp)) -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.common.kt new file mode 100644 index 0000000000..5fe8ddc318 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.common.kt @@ -0,0 +1,288 @@ +package chat.simplex.app.views.usersettings + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewSpaceBetween +import SectionSpacer +import SectionView +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.app.SimplexApp +import chat.simplex.app.TAG +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import chat.simplex.res.MR +import com.godaddy.android.colorpicker.ClassicColorPicker +import com.godaddy.android.colorpicker.HsvColor +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.serialization.encodeToString +import java.io.BufferedOutputStream +import java.util.* +import kotlin.collections.ArrayList + +object AppearanceScope { + @Composable + fun ThemesSection( + systemDarkTheme: SharedPreference, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + editColor: (ThemeColor, Color) -> Unit + ) { + val currentTheme by CurrentColors.collectAsState() + SectionView(stringResource(MR.strings.settings_section_title_themes)) { + val darkTheme = isSystemInDarkTheme() + val state = remember { derivedStateOf { currentTheme.name } } + ThemeSelector(state) { + ThemeManager.applyTheme(it, darkTheme) + } + if (state.value == DefaultTheme.SYSTEM.name) { + DarkThemeSelector(remember { systemDarkTheme.state }) { + ThemeManager.changeDarkTheme(it, darkTheme) + } + } + } + SectionItemView(showSettingsModal { _ -> CustomizeThemeView(editColor) }) { Text(stringResource(MR.strings.customize_theme_title)) } + } + + @Composable + fun CustomizeThemeView(editColor: (ThemeColor, Color) -> Unit) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + val currentTheme by CurrentColors.collectAsState() + + AppBarTitle(stringResource(MR.strings.customize_theme_title)) + + SectionView(stringResource(MR.strings.theme_colors_section_title)) { + SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY, currentTheme.colors.primary) }) { + val title = generalGetString(MR.strings.color_primary) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = MaterialTheme.colors.primary) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY_VARIANT, currentTheme.colors.primaryVariant) }) { + val title = generalGetString(MR.strings.color_primary_variant) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = MaterialTheme.colors.primaryVariant) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY, currentTheme.colors.secondary) }) { + val title = generalGetString(MR.strings.color_secondary) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = MaterialTheme.colors.secondary) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY_VARIANT, currentTheme.colors.secondaryVariant) }) { + val title = generalGetString(MR.strings.color_secondary_variant) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = MaterialTheme.colors.secondaryVariant) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.BACKGROUND, currentTheme.colors.background) }) { + val title = generalGetString(MR.strings.color_background) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = MaterialTheme.colors.background) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SURFACE, currentTheme.colors.surface) }) { + val title = generalGetString(MR.strings.color_surface) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = MaterialTheme.colors.surface) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.TITLE, currentTheme.appColors.title) }) { + val title = generalGetString(MR.strings.color_title) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.title) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE, currentTheme.appColors.sentMessage) }) { + val title = generalGetString(MR.strings.color_sent_message) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.sentMessage) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE, currentTheme.appColors.receivedMessage) }) { + val title = generalGetString(MR.strings.color_received_message) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.receivedMessage) + } + } + val isInDarkTheme = isInDarkTheme() + if (currentTheme.base.hasChangedAnyColor(currentTheme.colors, currentTheme.appColors)) { + SectionItemView({ ThemeManager.resetAllThemeColors(darkForSystemTheme = isInDarkTheme) }) { + Text(generalGetString(MR.strings.reset_color), color = MaterialTheme.colors.primary) + } + } + SectionSpacer() + SectionView { + val theme = remember { mutableStateOf(null as String?) } + val exportThemeLauncher = rememberSaveThemeLauncher(theme) + SectionItemView({ + val overrides = ThemeManager.currentThemeOverridesForExport(isInDarkTheme) + theme.value = yaml.encodeToString(overrides) + exportThemeLauncher.launch("simplex.theme") + }) { + Text(generalGetString(MR.strings.export_theme), color = MaterialTheme.colors.primary) + } + + val importThemeLauncher = rememberGetContentLauncher { uri: Uri? -> + if (uri != null) { + val theme = getThemeFromUri(uri) + if (theme != null) { + ThemeManager.saveAndApplyThemeOverrides(theme, isInDarkTheme) + } + } + } + // Can not limit to YAML mime type since it's unsupported by Android + SectionItemView({ importThemeLauncher.launch("*/*") }) { + Text(generalGetString(MR.strings.import_theme), color = MaterialTheme.colors.primary) + } + } + SectionBottomSpacer() + } + } + + @Composable + fun ColorEditor( + name: ThemeColor, + initialColor: Color, + close: () -> Unit, + ) { + Column( + Modifier + .fillMaxWidth() + ) { + AppBarTitle(name.text) + var currentColor by remember { mutableStateOf(initialColor) } + ColorPicker(initialColor) { + currentColor = it + } + + SectionSpacer() + val isInDarkTheme = isInDarkTheme() + TextButton( + onClick = { + ThemeManager.saveAndApplyThemeColor(name, currentColor, isInDarkTheme) + close() + }, + Modifier.align(Alignment.CenterHorizontally), + colors = ButtonDefaults.textButtonColors(contentColor = currentColor) + ) { + Text(generalGetString(MR.strings.save_color)) + } + } + } + + @Composable + fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) { + ClassicColorPicker( + color = initialColor, + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + showAlphaBar = true, + onColorChanged = { color: HsvColor -> + onColorChanged(color.toColor()) + } + ) + } + + @Composable + fun LangSelector(state: State, onSelected: (String) -> Unit) { + // Should be the same as in app/build.gradle's `android.defaultConfig.resConfigs` + val supportedLanguages = mapOf( + "system" to generalGetString(MR.strings.language_system), + "en" to "English", + "cs" to "Čeština", + "de" to "Deutsch", + "es" to "Español", + "fr" to "Français", + "it" to "Italiano", + "ja" to "日本語", + "nl" to "Nederlands", + "pl" to "Polski", + "pt-BR" to "Português (Brasil)", + "ru" to "Русский", + "zh-CN" to "简体中文" + ) + val values by remember { mutableStateOf(supportedLanguages.map { it.key to it.value }) } + ExposedDropDownSettingRow( + generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() }, + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) + } + + @Composable + private fun ThemeSelector(state: State, onSelected: (String) -> Unit) { + val darkTheme = isSystemInDarkTheme() + val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third }) } + ExposedDropDownSettingRow( + generalGetString(MR.strings.theme), + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) + } + + @Composable + private fun DarkThemeSelector(state: State, onSelected: (String) -> Unit) { + val values by remember { + val darkThemes = ArrayList>() + darkThemes.add(DefaultTheme.DARK.name to generalGetString(MR.strings.theme_dark)) + darkThemes.add(DefaultTheme.SIMPLEX.name to generalGetString(MR.strings.theme_simplex)) + mutableStateOf(darkThemes.toList()) + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.dark_theme), + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = { if (it != null) onSelected(it) } + ) + } + + //private fun openSystemLangPicker(activity: Activity) { + // activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName))) + //} + + @Composable + private fun rememberSaveThemeLauncher(theme: MutableState): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(), + onResult = { destination -> + val cxt = SimplexApp.context + try { + destination?.let { + val theme = theme.value + if (theme != null) { + val contentResolver = cxt.contentResolver + contentResolver.openOutputStream(destination)?.let { stream -> + BufferedOutputStream(stream).use { outputStream -> + theme.byteInputStream().use { it.copyTo(outputStream) } + } + Toast.makeText(cxt, generalGetString(MR.strings.file_saved), Toast.LENGTH_SHORT).show() + } + } + } + } catch (e: Error) { + Toast.makeText(cxt, generalGetString(MR.strings.error_saving_file), Toast.LENGTH_SHORT).show() + Log.e(TAG, "rememberSaveThemeLauncher error saving theme $e") + } finally { + theme.value = null + } + } + ) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt index cdf1f9900a..fba7ecc8ad 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt @@ -39,9 +39,13 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import chat.simplex.app.* import chat.simplex.app.R +import chat.simplex.app.helpers.saveAppLocale import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.usersettings.AppearanceScope.ColorEditor +import chat.simplex.app.views.usersettings.AppearanceScope.LangSelector +import chat.simplex.app.views.usersettings.AppearanceScope.ThemesSection import com.godaddy.android.colorpicker.* import chat.simplex.res.MR import kotlinx.coroutines.delay @@ -155,253 +159,11 @@ fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> } SectionDividerSpaced(maxTopPadding = true) - val currentTheme by CurrentColors.collectAsState() - SectionView(stringResource(MR.strings.settings_section_title_themes)) { - val darkTheme = isSystemInDarkTheme() - val state = remember { derivedStateOf { currentTheme.name } } - ThemeSelector(state) { - ThemeManager.applyTheme(it, darkTheme) - } - if (state.value == DefaultTheme.SYSTEM.name) { - DarkThemeSelector(remember { systemDarkTheme.state }) { - ThemeManager.changeDarkTheme(it, darkTheme) - } - } - } - SectionItemView(showSettingsModal { _ -> CustomizeThemeView(editColor) }) { Text(stringResource(MR.strings.customize_theme_title)) } + ThemesSection(systemDarkTheme, showSettingsModal, editColor) SectionBottomSpacer() } } -@Composable -fun CustomizeThemeView(editColor: (ThemeColor, Color) -> Unit) { - Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - ) { - val currentTheme by CurrentColors.collectAsState() - - AppBarTitle(stringResource(MR.strings.customize_theme_title)) - - SectionView(stringResource(MR.strings.theme_colors_section_title)) { - SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY, currentTheme.colors.primary) }) { - val title = generalGetString(MR.strings.color_primary) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.primary) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY_VARIANT, currentTheme.colors.primaryVariant) }) { - val title = generalGetString(MR.strings.color_primary_variant) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.primaryVariant) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY, currentTheme.colors.secondary) }) { - val title = generalGetString(MR.strings.color_secondary) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.secondary) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SECONDARY_VARIANT, currentTheme.colors.secondaryVariant) }) { - val title = generalGetString(MR.strings.color_secondary_variant) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.secondaryVariant) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.BACKGROUND, currentTheme.colors.background) }) { - val title = generalGetString(MR.strings.color_background) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.background) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SURFACE, currentTheme.colors.surface) }) { - val title = generalGetString(MR.strings.color_surface) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = colors.surface) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.TITLE, currentTheme.appColors.title) }) { - val title = generalGetString(MR.strings.color_title) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.title) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE, currentTheme.appColors.sentMessage) }) { - val title = generalGetString(MR.strings.color_sent_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.sentMessage) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE, currentTheme.appColors.receivedMessage) }) { - val title = generalGetString(MR.strings.color_received_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.appColors.receivedMessage) - } - } - val isInDarkTheme = isInDarkTheme() - if (currentTheme.base.hasChangedAnyColor(currentTheme.colors, currentTheme.appColors)) { - SectionItemView({ ThemeManager.resetAllThemeColors(darkForSystemTheme = isInDarkTheme) }) { - Text(generalGetString(MR.strings.reset_color), color = colors.primary) - } - } - SectionSpacer() - SectionView { - val theme = remember { mutableStateOf(null as String?) } - val exportThemeLauncher = rememberSaveThemeLauncher(theme) - SectionItemView({ - val overrides = ThemeManager.currentThemeOverridesForExport(isInDarkTheme) - theme.value = yaml.encodeToString(overrides) - exportThemeLauncher.launch("simplex.theme") - }) { - Text(generalGetString(MR.strings.export_theme), color = colors.primary) - } - - val importThemeLauncher = rememberGetContentLauncher { uri: Uri? -> - if (uri != null) { - val theme = getThemeFromUri(uri) - if (theme != null) { - ThemeManager.saveAndApplyThemeOverrides(theme, isInDarkTheme) - } - } - } - // Can not limit to YAML mime type since it's unsupported by Android - SectionItemView({ importThemeLauncher.launch("*/*") }) { - Text(generalGetString(MR.strings.import_theme), color = colors.primary) - } - } - SectionBottomSpacer() - } -} - -@Composable -fun ColorEditor( - name: ThemeColor, - initialColor: Color, - close: () -> Unit, -) { - Column( - Modifier - .fillMaxWidth() - ) { - AppBarTitle(name.text) - var currentColor by remember { mutableStateOf(initialColor) } - ColorPicker(initialColor) { - currentColor = it - } - - SectionSpacer() - val isInDarkTheme = isInDarkTheme() - TextButton( - onClick = { - ThemeManager.saveAndApplyThemeColor(name, currentColor, isInDarkTheme) - close() - }, - Modifier.align(Alignment.CenterHorizontally), - colors = ButtonDefaults.textButtonColors(contentColor = currentColor) - ) { - Text(generalGetString(MR.strings.save_color)) - } - } -} - -@Composable -fun ColorPicker(initialColor: Color, onColorChanged: (Color) -> Unit) { - ClassicColorPicker( - color = initialColor, - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - showAlphaBar = true, - onColorChanged = { color: HsvColor -> - onColorChanged(color.toColor()) - } - ) -} - -@Composable -private fun LangSelector(state: State, onSelected: (String) -> Unit) { - // Should be the same as in app/build.gradle's `android.defaultConfig.resConfigs` - val supportedLanguages = mapOf( - "system" to generalGetString(MR.strings.language_system), - "en" to "English", - "cs" to "Čeština", - "de" to "Deutsch", - "es" to "Español", - "fr" to "Français", - "it" to "Italiano", - "ja" to "日本語", - "nl" to "Nederlands", - "pl" to "Polski", - "pt-BR" to "Português (Brasil)", - "ru" to "Русский", - "zh-CN" to "简体中文" - ) - val values by remember { mutableStateOf(supportedLanguages.map { it.key to it.value }) } - ExposedDropDownSettingRow( - generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() }, - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = onSelected - ) -} - -@Composable -private fun ThemeSelector(state: State, onSelected: (String) -> Unit) { - val darkTheme = isSystemInDarkTheme() - val values by remember { mutableStateOf(ThemeManager.allThemes(darkTheme).map { it.second.name to it.third }) } - ExposedDropDownSettingRow( - generalGetString(MR.strings.theme), - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = onSelected - ) -} - -@Composable -private fun DarkThemeSelector(state: State, onSelected: (String) -> Unit) { - val values by remember { - val darkThemes = ArrayList>() - darkThemes.add(DefaultTheme.DARK.name to generalGetString(MR.strings.theme_dark)) - darkThemes.add(DefaultTheme.SIMPLEX.name to generalGetString(MR.strings.theme_simplex)) - mutableStateOf(darkThemes.toList()) - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.dark_theme), - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = { if (it != null) onSelected(it) } - ) -} - -//private fun openSystemLangPicker(activity: Activity) { -// activity.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.parse("package:" + SimplexApp.context.packageName))) -//} - -@Composable -private fun rememberSaveThemeLauncher(theme: MutableState): ManagedActivityResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(), - onResult = { destination -> - val cxt = SimplexApp.context - try { - destination?.let { - val theme = theme.value - if (theme != null) { - val contentResolver = cxt.contentResolver - contentResolver.openOutputStream(destination)?.let { stream -> - BufferedOutputStream(stream).use { outputStream -> - theme.byteInputStream().use { it.copyTo(outputStream) } - } - Toast.makeText(cxt, generalGetString(MR.strings.file_saved), Toast.LENGTH_SHORT).show() - } - } - } - } catch (e: Error) { - Toast.makeText(cxt, generalGetString(MR.strings.error_saving_file), Toast.LENGTH_SHORT).show() - Log.e(TAG, "rememberSaveThemeLauncher error saving theme $e") - } finally { - theme.value = null - } - } - ) - private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon -> SimplexApp.context.packageManager.getComponentEnabledSetting( ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}") diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt index 054686b0de..af58e98dff 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt @@ -16,23 +16,13 @@ import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import chat.simplex.app.* import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.NotificationsMode import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import kotlin.collections.ArrayList -enum class NotificationsMode(private val requiresIgnoringBatterySinceSdk: Int) { - OFF(Int.MAX_VALUE), PERIODIC(Build.VERSION_CODES.M), SERVICE(Build.VERSION_CODES.S), /*INSTANT(Int.MAX_VALUE) - for Firebase notifications */; - - val requiresIgnoringBattery - get() = requiresIgnoringBatterySinceSdk <= Build.VERSION.SDK_INT - - companion object { - val default: NotificationsMode = SERVICE - } -} - enum class NotificationPreviewMode { MESSAGE, CONTACT, HIDDEN; diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.common.kt new file mode 100644 index 0000000000..3ec12a00e8 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.common.kt @@ -0,0 +1,484 @@ +package chat.simplex.app.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionTextFooter +import SectionView +import android.view.WindowManager +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.FragmentActivity +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.ProfileNameField +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword +import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword +import chat.simplex.app.views.isValidDisplayName +import chat.simplex.app.views.localauth.SetAppPasscodeView +import chat.simplex.app.views.onboarding.ReadableText +import chat.simplex.res.MR + +enum class LAMode { + SYSTEM, + PASSCODE; + + val text: String + get() = when (this) { + SYSTEM -> generalGetString(MR.strings.la_mode_system) + PASSCODE -> generalGetString(MR.strings.la_mode_passcode) + } +} + +@Composable +fun PrivacySettingsView( + chatModel: ChatModel, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + setPerformLA: (Boolean) -> Unit +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode + AppBarTitle(stringResource(MR.strings.your_privacy)) + PrivacyDeviceSection(showSettingsModal, setPerformLA) + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_chats)) { + SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) + SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) + SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = { + simplexLinkMode.set(it) + chatModel.simplexLinkMode.value = it + }) + } + if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) { + SectionTextFooter(stringResource(MR.strings.simplex_link_mode_browser_warning)) + } + SectionBottomSpacer() + } +} + +@Composable +private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { + val values = remember { + SimplexLinkMode.values().map { + when (it) { + SimplexLinkMode.DESCRIPTION -> it to generalGetString(MR.strings.simplex_link_mode_description) + SimplexLinkMode.FULL -> it to generalGetString(MR.strings.simplex_link_mode_full) + SimplexLinkMode.BROWSER -> it to generalGetString(MR.strings.simplex_link_mode_browser) + } + } + } + ExposedDropDownSettingRow( + generalGetString(MR.strings.simplex_link_mode), + values, + simplexLinkModeState, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) +} + +private val laDelays = listOf(10, 30, 60, 180, 0) + +@Composable +fun SimplexLockView( + chatModel: ChatModel, + currentLAMode: SharedPreference, + setPerformLA: (Boolean) -> Unit +) { + val performLA = remember { chatModel.performLA } + val laMode = remember { chatModel.controller.appPrefs.laMode.state } + val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay } + val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } } + val activity = LocalContext.current as FragmentActivity + val selfDestructPref = remember { chatModel.controller.appPrefs.selfDestruct } + val selfDestructDisplayName = remember { mutableStateOf(chatModel.controller.appPrefs.selfDestructDisplayName.get() ?: "") } + val selfDestructDisplayNamePref = remember { chatModel.controller.appPrefs.selfDestructDisplayName } + + fun resetLAEnabled(onOff: Boolean) { + chatModel.controller.appPrefs.performLA.set(onOff) + chatModel.performLA.value = onOff + } + + fun disableUnavailableLA() { + resetLAEnabled(false) + currentLAMode.set(LAMode.SYSTEM) + laUnavailableInstructionAlert() + } + + fun resetSelfDestruct() { + selfDestructPref.set(false) + ksSelfDestructPassword.remove() + } + + fun toggleLAMode(toLAMode: LAMode) { + authenticate( + if (toLAMode == LAMode.SYSTEM) { + generalGetString(MR.strings.la_enter_app_passcode) + } else { + generalGetString(MR.strings.chat_lock) + }, + generalGetString(MR.strings.change_lock_mode) + ) { laResult -> + when (laResult) { + is LAResult.Error -> { + laFailedAlert() + } + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + LAResult.Success -> { + when (toLAMode) { + LAMode.SYSTEM -> { + authenticate(generalGetString(MR.strings.auth_enable_simplex_lock), promptSubtitle = "", usingLAMode = toLAMode) { laResult -> + when (laResult) { + LAResult.Success -> { + currentLAMode.set(toLAMode) + ksAppPassword.remove() + resetSelfDestruct() + laTurnedOnAlert() + } + is LAResult.Unavailable, is LAResult.Error -> laFailedAlert() + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + } + } + } + LAMode.PASSCODE -> { + ModalManager.shared.showCustomModal { close -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + submit = { + laLockDelay.set(30) + currentLAMode.set(toLAMode) + passcodeAlert(generalGetString(MR.strings.passcode_set)) + }, + cancel = {}, + close = close + ) + } + } + } + } + } + is LAResult.Unavailable -> disableUnavailableLA() + } + } + } + + fun toggleSelfDestruct(selfDestruct: SharedPreference) { + authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_mode)) { laResult -> + when (laResult) { + is LAResult.Error -> laFailedAlert() + is LAResult.Failed -> { /* Can be called multiple times on every failure */ } + LAResult.Success -> { + if (!selfDestruct.get()) { + ModalManager.shared.showCustomModal { close -> + EnableSelfDestruct(selfDestruct, close) + } + } else { + resetSelfDestruct() + } + } + is LAResult.Unavailable -> disableUnavailableLA() + } + } + } + + fun changeLAPassword() { + authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode)) { laResult -> + when (laResult) { + LAResult.Success -> { + ModalManager.shared.showCustomModal { close -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + submit = { + passcodeAlert(generalGetString(MR.strings.passcode_changed)) + }, cancel = { + passcodeAlert(generalGetString(MR.strings.passcode_not_changed)) + }, close = close + ) + } + } + } + is LAResult.Error -> laFailedAlert() + is LAResult.Failed -> {} + is LAResult.Unavailable -> disableUnavailableLA() + } + } + } + + fun changeSelfDestructPassword() { + authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode)) { laResult -> + when (laResult) { + LAResult.Success -> { + ModalManager.shared.showCustomModal { close -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + passcodeKeychain = ksSelfDestructPassword, + submit = { + selfDestructPasscodeAlert(generalGetString(MR.strings.self_destruct_passcode_changed)) + }, cancel = { + passcodeAlert(generalGetString(MR.strings.passcode_not_changed)) + }, + close = close + ) + } + } + } + is LAResult.Error -> laFailedAlert() + is LAResult.Failed -> {} + is LAResult.Unavailable -> disableUnavailableLA() + } + } + } + + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + AppBarTitle(stringResource(MR.strings.chat_lock)) + SectionView { + EnableLock(performLA) { performLAToggle -> + performLA.value = performLAToggle + chatModel.controller.appPrefs.laNoticeShown.set(true) + if (performLAToggle) { + when (currentLAMode.state.value) { + LAMode.SYSTEM -> { + setPerformLA(true) + } + LAMode.PASSCODE -> { + ModalManager.shared.showCustomModal { close -> + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + submit = { + laLockDelay.set(30) + chatModel.controller.appPrefs.performLA.set(true) + passcodeAlert(generalGetString(MR.strings.passcode_set)) + }, + cancel = { + resetLAEnabled(false) + }, + close = close + ) + } + } + } + } + } else { + setPerformLA(false) + } + } + LockModeSelector(laMode) { newLAMode -> + if (laMode.value == newLAMode) return@LockModeSelector + if (chatModel.controller.appPrefs.performLA.get()) { + toggleLAMode(newLAMode) + } else { + currentLAMode.set(newLAMode) + } + } + + if (performLA.value) { + LockDelaySelector(remember { laLockDelay.state }) { laLockDelay.set(it) } + if (showChangePasscode.value && laMode.value == LAMode.PASSCODE) { + SectionItemView({ changeLAPassword() }) { + Text( + generalGetString(MR.strings.la_change_app_passcode), + color = MaterialTheme.colors.primary + ) + } + } + } + if (performLA.value && laMode.value == LAMode.PASSCODE) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.self_destruct_passcode).uppercase()) { + val openInfo = { + ModalManager.shared.showModal { + SelfDestructInfoView() + } + } + SettingsActionItemWithContent(null, null, click = openInfo) { + SharedPreferenceToggleWithIcon( + stringResource(MR.strings.enable_self_destruct), + painterResource(MR.images.ic_info), + openInfo, + remember { selfDestructPref.state }.value + ) { + toggleSelfDestruct(selfDestructPref) + } + } + + if (remember { selfDestructPref.state }.value) { + Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) { + Text( + stringResource(MR.strings.self_destruct_new_display_name), + fontSize = 16.sp, + modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) + ) + ProfileNameField(selfDestructDisplayName, "", ::isValidDisplayName) + LaunchedEffect(selfDestructDisplayName.value) { + val new = selfDestructDisplayName.value + if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) { + selfDestructDisplayNamePref.set(new) + } + } + } + SectionItemView({ changeSelfDestructPassword() }) { + Text( + stringResource(MR.strings.change_self_destruct_passcode), + color = MaterialTheme.colors.primary + ) + } + } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun SelfDestructInfoView() { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), + ) { + AppBarTitle(stringResource(MR.strings.self_destruct), withPadding = false) + ReadableText(stringResource(MR.strings.if_you_enter_self_destruct_code)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TextListItem("1.", stringResource(MR.strings.all_app_data_will_be_cleared)) + TextListItem("2.", stringResource(MR.strings.app_passcode_replaced_with_self_destruct)) + TextListItem("3.", stringResource(MR.strings.empty_chat_profile_is_created)) + } + SectionBottomSpacer() + } +} + +@Composable +private fun EnableSelfDestruct( + selfDestruct: SharedPreference, + close: () -> Unit +) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + SetAppPasscodeView( + passcodeKeychain = ksSelfDestructPassword, title = generalGetString(MR.strings.set_passcode), reason = generalGetString(MR.strings.enabled_self_destruct_passcode), + submit = { + selfDestruct.set(true) + selfDestructPasscodeAlert(generalGetString(MR.strings.self_destruct_passcode_enabled)) + }, + cancel = {}, + close = close + ) + } +} + +@Composable +private fun EnableLock(performLA: MutableState, onCheckedChange: (Boolean) -> Unit) { + SectionItemView { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(MR.strings.enable_lock), Modifier + .padding(end = 24.dp) + .fillMaxWidth() + .weight(1F) + ) + DefaultSwitch( + checked = performLA.value, + onCheckedChange = onCheckedChange, + ) + } + } +} + +@Composable +private fun LockModeSelector(state: State, onSelected: (LAMode) -> Unit) { + val values by remember { mutableStateOf(LAMode.values().map { it to it.text }) } + ExposedDropDownSettingRow( + generalGetString(MR.strings.lock_mode), + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) +} + +@Composable +private fun LockDelaySelector(state: State, onSelected: (Int) -> Unit) { + val delays = remember { if (laDelays.contains(state.value)) laDelays else listOf(state.value) + laDelays } + val values by remember { mutableStateOf(delays.map { it to laDelayText(it) }) } + ExposedDropDownSettingRow( + generalGetString(MR.strings.lock_after), + values, + state, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = onSelected + ) +} + +@Composable +private fun TextListItem(n: String, text: String) { + Box { + Text(n) + Text(text, Modifier.padding(start = 20.dp)) + } +} + +private fun laDelayText(t: Int): String { + val m = t / 60 + val s = t % 60 + return if (t == 0) { + generalGetString(MR.strings.la_immediately) + } else if (m == 0 || s != 0) { + // there are no options where both minutes and seconds are needed + generalGetString(MR.strings.la_seconds).format(s) + } else { + generalGetString(MR.strings.la_minutes).format(m) + } +} + +private fun passcodeAlert(title: String) { + AlertManager.shared.showAlertMsg( + title = title, + text = generalGetString(MR.strings.la_please_remember_to_store_password) + ) +} + +private fun selfDestructPasscodeAlert(title: String) { + AlertManager.shared.showAlertMsg(title, generalGetString(MR.strings.if_you_enter_passcode_data_removed)) +} + +fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.auth_simplex_lock_turned_on), + generalGetString(MR.strings.auth_you_will_be_required_to_authenticate_when_you_start_or_resume) +) + +fun laPasscodeNotSetAlert() = AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.lock_not_enabled), + generalGetString(MR.strings.you_can_turn_on_lock) +) + +fun laFailedAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.la_auth_failed), + text = generalGetString(MR.strings.la_could_not_be_verified) + ) +} + +fun laUnavailableInstructionAlert() = AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.auth_unavailable), + generalGetString(MR.strings.auth_device_authentication_is_not_enabled_you_can_turn_on_in_settings_once_enabled) +) + +fun laUnavailableTurningOffAlert() = AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.auth_unavailable), + generalGetString(MR.strings.auth_device_authentication_is_disabled_turning_off) +) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt index d4f8d1eccc..6b2cc49b0d 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt @@ -1,471 +1,33 @@ package chat.simplex.app.views.usersettings -import SectionBottomSpacer -import SectionDividerSpaced -import SectionItemView -import SectionTextFooter import SectionView import android.view.WindowManager -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity +import chat.simplex.app.model.ChatController +import chat.simplex.app.model.ChatModel +import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.fragment.app.FragmentActivity -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.ProfileNameField -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword -import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword -import chat.simplex.app.views.isValidDisplayName -import chat.simplex.app.views.localauth.SetAppPasscodeView -import chat.simplex.app.views.onboarding.ReadableText -import chat.simplex.res.MR - -enum class LAMode { - SYSTEM, - PASSCODE; - - val text: String - get() = when (this) { - SYSTEM -> generalGetString(MR.strings.la_mode_system) - PASSCODE -> generalGetString(MR.strings.la_mode_passcode) - } -} @Composable -fun PrivacySettingsView( - chatModel: ChatModel, +fun PrivacyDeviceSection( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - setPerformLA: (Boolean) -> Unit + setPerformLA: (Boolean) -> Unit, ) { - Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - ) { - val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode - AppBarTitle(stringResource(MR.strings.your_privacy)) - SectionView(stringResource(MR.strings.settings_section_title_device)) { - ChatLockItem(chatModel, showSettingsModal, setPerformLA) - val context = LocalContext.current - SettingsPreferenceItem(painterResource(MR.images.ic_visibility_off), stringResource(MR.strings.protect_app_screen), chatModel.controller.appPrefs.privacyProtectScreen) { on -> - if (on) { - (context as? FragmentActivity)?.window?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ) - } else { - (context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - } - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_chats)) { - SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) - SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) - SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = { - simplexLinkMode.set(it) - chatModel.simplexLinkMode.value = it - }) - } - if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) { - SectionTextFooter(stringResource(MR.strings.simplex_link_mode_browser_warning)) - } - SectionBottomSpacer() - } -} - -@Composable -private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { - val values = remember { - SimplexLinkMode.values().map { - when (it) { - SimplexLinkMode.DESCRIPTION -> it to generalGetString(MR.strings.simplex_link_mode_description) - SimplexLinkMode.FULL -> it to generalGetString(MR.strings.simplex_link_mode_full) - SimplexLinkMode.BROWSER -> it to generalGetString(MR.strings.simplex_link_mode_browser) - } - } - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.simplex_link_mode), - values, - simplexLinkModeState, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = onSelected - ) -} - -private val laDelays = listOf(10, 30, 60, 180, 0) - -@Composable -fun SimplexLockView( - chatModel: ChatModel, - currentLAMode: SharedPreference, - setPerformLA: (Boolean) -> Unit -) { - val performLA = remember { chatModel.performLA } - val laMode = remember { chatModel.controller.appPrefs.laMode.state } - val laLockDelay = remember { chatModel.controller.appPrefs.laLockDelay } - val showChangePasscode = remember { derivedStateOf { performLA.value && currentLAMode.state.value == LAMode.PASSCODE } } - val activity = LocalContext.current as FragmentActivity - val selfDestructPref = remember { chatModel.controller.appPrefs.selfDestruct } - val selfDestructDisplayName = remember { mutableStateOf(chatModel.controller.appPrefs.selfDestructDisplayName.get() ?: "") } - val selfDestructDisplayNamePref = remember { chatModel.controller.appPrefs.selfDestructDisplayName } - - fun resetLAEnabled(onOff: Boolean) { - chatModel.controller.appPrefs.performLA.set(onOff) - chatModel.performLA.value = onOff - } - - fun disableUnavailableLA() { - resetLAEnabled(false) - currentLAMode.set(LAMode.SYSTEM) - laUnavailableInstructionAlert() - } - - fun resetSelfDestruct() { - selfDestructPref.set(false) - ksSelfDestructPassword.remove() - } - - fun toggleLAMode(toLAMode: LAMode) { - authenticate( - if (toLAMode == LAMode.SYSTEM) { - generalGetString(MR.strings.la_enter_app_passcode) + SectionView(stringResource(MR.strings.settings_section_title_device)) { + ChatLockItem(ChatModel, showSettingsModal, setPerformLA) + val context = LocalContext.current + SettingsPreferenceItem(painterResource(MR.images.ic_visibility_off), stringResource(MR.strings.protect_app_screen), ChatController.appPrefs.privacyProtectScreen) { on -> + if (on) { + (context as? FragmentActivity)?.window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) } else { - generalGetString(MR.strings.chat_lock) - }, - generalGetString(MR.strings.change_lock_mode) - ) { laResult -> - when (laResult) { - is LAResult.Error -> { - laFailedAlert() - } - is LAResult.Failed -> { /* Can be called multiple times on every failure */ } - LAResult.Success -> { - when (toLAMode) { - LAMode.SYSTEM -> { - authenticate(generalGetString(MR.strings.auth_enable_simplex_lock), promptSubtitle = "", usingLAMode = toLAMode) { laResult -> - when (laResult) { - LAResult.Success -> { - currentLAMode.set(toLAMode) - ksAppPassword.remove() - resetSelfDestruct() - laTurnedOnAlert() - } - is LAResult.Unavailable, is LAResult.Error -> laFailedAlert() - is LAResult.Failed -> { /* Can be called multiple times on every failure */ } - } - } - } - LAMode.PASSCODE -> { - ModalManager.shared.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - SetAppPasscodeView( - submit = { - laLockDelay.set(30) - currentLAMode.set(toLAMode) - passcodeAlert(generalGetString(MR.strings.passcode_set)) - }, - cancel = {}, - close = close - ) - } - } - } - } - } - is LAResult.Unavailable -> disableUnavailableLA() + (context as? FragmentActivity)?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } } - - fun toggleSelfDestruct(selfDestruct: SharedPreference) { - authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_mode)) { laResult -> - when (laResult) { - is LAResult.Error -> laFailedAlert() - is LAResult.Failed -> { /* Can be called multiple times on every failure */ } - LAResult.Success -> { - if (!selfDestruct.get()) { - ModalManager.shared.showCustomModal { close -> - EnableSelfDestruct(selfDestruct, close) - } - } else { - resetSelfDestruct() - } - } - is LAResult.Unavailable -> disableUnavailableLA() - } - } - } - - fun changeLAPassword() { - authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode)) { laResult -> - when (laResult) { - LAResult.Success -> { - ModalManager.shared.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - SetAppPasscodeView( - submit = { - passcodeAlert(generalGetString(MR.strings.passcode_changed)) - }, cancel = { - passcodeAlert(generalGetString(MR.strings.passcode_not_changed)) - }, close = close - ) - } - } - } - is LAResult.Error -> laFailedAlert() - is LAResult.Failed -> {} - is LAResult.Unavailable -> disableUnavailableLA() - } - } - } - - fun changeSelfDestructPassword() { - authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode)) { laResult -> - when (laResult) { - LAResult.Success -> { - ModalManager.shared.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - SetAppPasscodeView( - passcodeKeychain = ksSelfDestructPassword, - submit = { - selfDestructPasscodeAlert(generalGetString(MR.strings.self_destruct_passcode_changed)) - }, cancel = { - passcodeAlert(generalGetString(MR.strings.passcode_not_changed)) - }, - close = close - ) - } - } - } - is LAResult.Error -> laFailedAlert() - is LAResult.Failed -> {} - is LAResult.Unavailable -> disableUnavailableLA() - } - } - } - - Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - ) { - AppBarTitle(stringResource(MR.strings.chat_lock)) - SectionView { - EnableLock(performLA) { performLAToggle -> - performLA.value = performLAToggle - chatModel.controller.appPrefs.laNoticeShown.set(true) - if (performLAToggle) { - when (currentLAMode.state.value) { - LAMode.SYSTEM -> { - setPerformLA(true) - } - LAMode.PASSCODE -> { - ModalManager.shared.showCustomModal { close -> - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - SetAppPasscodeView( - submit = { - laLockDelay.set(30) - chatModel.controller.appPrefs.performLA.set(true) - passcodeAlert(generalGetString(MR.strings.passcode_set)) - }, - cancel = { - resetLAEnabled(false) - }, - close = close - ) - } - } - } - } - } else { - setPerformLA(false) - } - } - LockModeSelector(laMode) { newLAMode -> - if (laMode.value == newLAMode) return@LockModeSelector - if (chatModel.controller.appPrefs.performLA.get()) { - toggleLAMode(newLAMode) - } else { - currentLAMode.set(newLAMode) - } - } - - if (performLA.value) { - LockDelaySelector(remember { laLockDelay.state }) { laLockDelay.set(it) } - if (showChangePasscode.value && laMode.value == LAMode.PASSCODE) { - SectionItemView({ changeLAPassword() }) { - Text( - generalGetString(MR.strings.la_change_app_passcode), - color = MaterialTheme.colors.primary - ) - } - } - } - if (performLA.value && laMode.value == LAMode.PASSCODE) { - SectionDividerSpaced() - SectionView(stringResource(MR.strings.self_destruct_passcode).uppercase()) { - val openInfo = { - ModalManager.shared.showModal { - SelfDestructInfoView() - } - } - SettingsActionItemWithContent(null, null, click = openInfo) { - SharedPreferenceToggleWithIcon( - stringResource(MR.strings.enable_self_destruct), - painterResource(MR.images.ic_info), - openInfo, - remember { selfDestructPref.state }.value - ) { - toggleSelfDestruct(selfDestructPref) - } - } - - if (remember { selfDestructPref.state }.value) { - Column(Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) { - Text( - stringResource(MR.strings.self_destruct_new_display_name), - fontSize = 16.sp, - modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) - ) - ProfileNameField(selfDestructDisplayName, "", ::isValidDisplayName) - LaunchedEffect(selfDestructDisplayName.value) { - val new = selfDestructDisplayName.value - if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) { - selfDestructDisplayNamePref.set(new) - } - } - } - SectionItemView({ changeSelfDestructPassword() }) { - Text( - stringResource(MR.strings.change_self_destruct_passcode), - color = MaterialTheme.colors.primary - ) - } - } - } - } - } - SectionBottomSpacer() - } -} - -@Composable -private fun SelfDestructInfoView() { - Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), - ) { - AppBarTitle(stringResource(MR.strings.self_destruct), withPadding = false) - ReadableText(stringResource(MR.strings.if_you_enter_self_destruct_code)) - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - TextListItem("1.", stringResource(MR.strings.all_app_data_will_be_cleared)) - TextListItem("2.", stringResource(MR.strings.app_passcode_replaced_with_self_destruct)) - TextListItem("3.", stringResource(MR.strings.empty_chat_profile_is_created)) - } - SectionBottomSpacer() - } -} - -@Composable -private fun EnableSelfDestruct( - selfDestruct: SharedPreference, - close: () -> Unit -) { - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { - SetAppPasscodeView( - passcodeKeychain = ksSelfDestructPassword, title = generalGetString(MR.strings.set_passcode), reason = generalGetString(MR.strings.enabled_self_destruct_passcode), - submit = { - selfDestruct.set(true) - selfDestructPasscodeAlert(generalGetString(MR.strings.self_destruct_passcode_enabled)) - }, - cancel = {}, - close = close - ) - } -} - -@Composable -private fun EnableLock(performLA: MutableState, onCheckedChange: (Boolean) -> Unit) { - SectionItemView { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(MR.strings.enable_lock), Modifier - .padding(end = 24.dp) - .fillMaxWidth() - .weight(1F) - ) - DefaultSwitch( - checked = performLA.value, - onCheckedChange = onCheckedChange, - ) - } - } -} - -@Composable -private fun LockModeSelector(state: State, onSelected: (LAMode) -> Unit) { - val values by remember { mutableStateOf(LAMode.values().map { it to it.text }) } - ExposedDropDownSettingRow( - generalGetString(MR.strings.lock_mode), - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = onSelected - ) -} - -@Composable -private fun LockDelaySelector(state: State, onSelected: (Int) -> Unit) { - val delays = remember { if (laDelays.contains(state.value)) laDelays else listOf(state.value) + laDelays } - val values by remember { mutableStateOf(delays.map { it to laDelayText(it) }) } - ExposedDropDownSettingRow( - generalGetString(MR.strings.lock_after), - values, - state, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = onSelected - ) -} - -@Composable -private fun TextListItem(n: String, text: String) { - Box { - Text(n) - Text(text, Modifier.padding(start = 20.dp)) - } -} - -private fun laDelayText(t: Int): String { - val m = t / 60 - val s = t % 60 - return if (t == 0) { - generalGetString(MR.strings.la_immediately) - } else if (m == 0 || s != 0) { - // there are no options where both minutes and seconds are needed - generalGetString(MR.strings.la_seconds).format(s) - } else { - generalGetString(MR.strings.la_minutes).format(m) - } -} - -private fun passcodeAlert(title: String) { - AlertManager.shared.showAlertMsg( - title = title, - text = generalGetString(MR.strings.la_please_remember_to_store_password) - ) -} - -private fun selfDestructPasscodeAlert(title: String) { - AlertManager.shared.showAlertMsg(title, generalGetString(MR.strings.if_you_enter_passcode_data_removed)) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.common.kt new file mode 100644 index 0000000000..c80c69ab92 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.common.kt @@ -0,0 +1,42 @@ +package chat.simplex.app.views.usersettings + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.app.model.ServerCfg +import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.QRCodeScanner +import chat.simplex.res.MR + +@Composable +fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = DEFAULT_PADDING) + ) { + AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr), false) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(ratio = 1F) + .padding(bottom = 12.dp) + ) { + QRCodeScanner { text -> + val res = parseServerAddress(text) + if (res != null) { + onNext(ServerCfg(text, false, null, true)) + } else { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_invalid_address), + text = generalGetString(MR.strings.smp_servers_check_address) + ) + } + } + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.kt index 69f1e31435..443bce6dca 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.kt @@ -1,20 +1,10 @@ package chat.simplex.app.views.usersettings import android.Manifest -import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress import chat.simplex.app.model.ServerCfg -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCodeScanner import com.google.accompanist.permissions.rememberPermissionState -import chat.simplex.res.MR @Composable fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { @@ -24,32 +14,3 @@ fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { } ScanProtocolServerLayout(onNext) } - -@Composable -private fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { - Column( - Modifier - .fillMaxSize() - .padding(horizontal = DEFAULT_PADDING) - ) { - AppBarTitle(stringResource(MR.strings.smp_servers_scan_qr), false) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(ratio = 1F) - .padding(bottom = 12.dp) - ) { - QRCodeScanner { text -> - val res = parseServerAddress(text) - if (res != null) { - onNext(ServerCfg(text, false, null, true)) - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.smp_servers_invalid_address), - text = generalGetString(MR.strings.smp_servers_check_address) - ) - } - } - } - } -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.common.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.common.kt new file mode 100644 index 0000000000..7c689c71a9 --- /dev/null +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.common.kt @@ -0,0 +1,511 @@ +package chat.simplex.app.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewWithIcon +import SectionView +import TextIconSpaced +import android.content.res.Configuration +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.* +import androidx.work.WorkManager +import chat.simplex.app.* +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.database.DatabaseView +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.onboarding.SimpleXInfo +import chat.simplex.app.views.onboarding.WhatsNewView +import chat.simplex.res.MR +import com.jakewharton.processphoenix.ProcessPhoenix + +@Composable +fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { + val user = chatModel.currentUser.value + val stopped = chatModel.chatRunning.value == false + + MaintainIncognitoState(chatModel) + + if (user != null) { + val requireAuth = remember { chatModel.controller.appPrefs.performLA.state } + SettingsLayout( + profile = user.profile, + stopped, + chatModel.chatDbEncrypted.value == true, + chatModel.incognito, + chatModel.controller.appPrefs.incognito, + user.displayName, + setPerformLA = setPerformLA, + showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, + showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } }, + showSettingsModalWithSearch = { modalView -> + ModalManager.shared.showCustomModal { close -> + val search = rememberSaveable { mutableStateOf("") } + ModalView( + { close() }, + endButtons = { + SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = true) { search.value = it } + }, + content = { modalView(chatModel, search) }) + } + }, + showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, + showVersion = { + withApi { + val info = chatModel.controller.apiGetVersion() + if (info != null) { + ModalManager.shared.showModal { VersionInfoView(info) } + } + } + }, + withAuth = { title, desc, block -> + if (!requireAuth.value) { + block() + } else { + var autoShow = true + ModalManager.shared.showModalCloseable { close -> + val onFinishAuth = { success: Boolean -> + if (success) { + close() + block() + } + } + + LaunchedEffect(Unit) { + if (autoShow) { + autoShow = false + runAuth(title, desc, onFinishAuth) + } + } + Box( + Modifier.fillMaxSize().background(MaterialTheme.colors.background), + contentAlignment = Alignment.Center + ) { + SimpleButton( + stringResource(MR.strings.auth_unlock), + icon = painterResource(MR.images.ic_lock), + click = { + runAuth(title, desc, onFinishAuth) + } + ) + } + } + } + }, + ) + } +} + +val simplexTeamUri = + "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" + +@Composable +fun SettingsLayout( + profile: LocalProfile, + stopped: Boolean, + encrypted: Boolean, + incognito: MutableState, + incognitoPref: SharedPreference, + userDisplayName: String, + setPerformLA: (Boolean) -> Unit, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showSettingsModalWithSearch: (@Composable (ChatModel, MutableState) -> Unit) -> Unit, + showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), + showVersion: () -> Unit, + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit +) { + val theme = CurrentColors.collectAsState() + val uriHandler = LocalUriHandler.current + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).themedBackground(theme.value.base)) { + Column( + Modifier + .fillMaxSize() + .padding(top = DEFAULT_PADDING) + ) { + AppBarTitle(stringResource(MR.strings.your_settings)) + + SectionView(stringResource(MR.strings.settings_section_title_you)) { + SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { + ProfilePreview(profile, stopped = stopped) + } + val profileHidden = rememberSaveable { mutableStateOf(false) } + SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) + SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() } + SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) + ChatPreferencesItem(showCustomModal, stopped = stopped) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_settings)) { + SettingsActionItem(painterResource(MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) + DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_help)) { + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openUriCatching(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true) + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_support)) { + ContributeItem(uriHandler) + RateAppItem(uriHandler) + StarOnGithubItem(uriHandler) + } + SectionDividerSpaced() + + SettingsSectionApp(showSettingsModal, showCustomModal, showVersion, withAuth) + SectionBottomSpacer() + } + } +} + +@Composable +fun SettingsIncognitoActionItem( + incognitoPref: SharedPreference, + incognito: MutableState, + stopped: Boolean, + onClickInfo: () -> Unit, +) { + SettingsPreferenceItemWithInfo( + if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), + if (incognito.value) Indigo else MaterialTheme.colors.secondary, + stringResource(MR.strings.incognito), + stopped, + onClickInfo, + incognitoPref, + incognito + ) +} + +@Composable +fun MaintainIncognitoState(chatModel: ChatModel) { + // Cache previous value and once it changes in background, update it via API + var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) } + LaunchedEffect(chatModel.incognito.value) { + // Don't do anything if nothing changed + if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect + try { + chatModel.controller.apiSetIncognito(chatModel.incognito.value) + } catch (e: Exception) { + // Rollback the state + chatModel.controller.appPrefs.incognito.set(cachedIncognito) + // Crash the app + throw e + } + cachedIncognito = chatModel.incognito.value + } +} + +@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { + SectionItemViewWithIcon(openDatabaseView) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(Modifier.weight(1f)) { + Icon( + painterResource(MR.images.ic_database), + contentDescription = stringResource(MR.strings.database_passphrase_and_export), + tint = if (encrypted) MaterialTheme.colors.secondary else WarningOrange, + ) + TextIconSpaced(true) + Text(stringResource(MR.strings.database_passphrase_and_export)) + } + if (stopped) { + Icon( + painterResource(MR.images.ic_report_filled), + contentDescription = stringResource(MR.strings.chat_is_stopped), + tint = Color.Red, + modifier = Modifier.padding(end = 6.dp) + ) + } + } + } +} + +@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit)), stopped: Boolean) { + SettingsActionItem( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.chat_preferences), + click = if (stopped) null else ({ + withApi { + showCustomModal { m, close -> + PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close) + }() + } + }), + disabled = stopped, + extraPadding = true + ) +} + +@Composable +fun ChatLockItem( + chatModel: ChatModel, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + setPerformLA: (Boolean) -> Unit +) { + val performLA = remember { chatModel.performLA } + val currentLAMode = remember { chatModel.controller.appPrefs.laMode } + SettingsActionItemWithContent( + click = showSettingsModal { SimplexLockView(chatModel, currentLAMode, setPerformLA) }, + icon = if (performLA.value) painterResource(MR.images.ic_lock_filled) else painterResource(MR.images.ic_lock), + text = stringResource(MR.strings.chat_lock), + iconColor = if (performLA.value) SimplexGreen else MaterialTheme.colors.secondary, + extraPadding = false, + ) { + Text(if (performLA.value) remember { currentLAMode.state }.value.text else generalGetString(MR.strings.la_mode_off), color = MaterialTheme.colors.secondary) + } +} + +@Composable private fun ContributeItem(uriHandler: UriHandler) { + SectionItemViewWithIcon({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#contribute") }) { + Icon( + painterResource(MR.images.ic_keyboard), + contentDescription = "GitHub", + tint = MaterialTheme.colors.secondary, + ) + TextIconSpaced(extraPadding = true) + Text(generalGetString(MR.strings.contribute), color = MaterialTheme.colors.primary) + } +} + +@Composable private fun RateAppItem(uriHandler: UriHandler) { + SectionItemViewWithIcon({ + runCatching { uriHandler.openUriCatching("market://details?id=chat.simplex.app") } + .onFailure { uriHandler.openUriCatching("https://play.google.com/store/apps/details?id=chat.simplex.app") } + } + ) { + Icon( + painterResource(MR.images.ic_star), + contentDescription = "Google Play", + tint = MaterialTheme.colors.secondary, + ) + TextIconSpaced(extraPadding = true) + Text(generalGetString(MR.strings.rate_the_app), color = MaterialTheme.colors.primary) + } +} + +@Composable private fun StarOnGithubItem(uriHandler: UriHandler) { + SectionItemViewWithIcon({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { + Icon( + painter = painterResource(MR.images.ic_github), + contentDescription = "GitHub", + tint = MaterialTheme.colors.secondary, + ) + TextIconSpaced(extraPadding = true) + Text(generalGetString(MR.strings.star_on_github), color = MaterialTheme.colors.primary) + } +} + +@Composable fun ChatConsoleItem(showTerminal: () -> Unit) { + SectionItemView(showTerminal) { + Icon( + painter = painterResource(MR.images.ic_outline_terminal), + contentDescription = stringResource(MR.strings.chat_console), + tint = MaterialTheme.colors.secondary, + ) + TextIconSpaced() + Text(stringResource(MR.strings.chat_console)) + } +} + +@Composable fun InstallTerminalAppItem(uriHandler: UriHandler) { + SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { + Icon( + painter = painterResource(MR.images.ic_github), + contentDescription = "GitHub", + tint = MaterialTheme.colors.secondary, + ) + TextIconSpaced() + Text(generalGetString(MR.strings.install_simplex_chat_for_terminal), color = MaterialTheme.colors.primary) + } +} + +@Composable fun AppVersionItem(showVersion: () -> Unit) { + SectionItemViewWithIcon(showVersion) { AppVersionText() } +} + +@Composable fun AppVersionText() { + Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") +} + +@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) { + ProfileImage(size = size, image = profileOf.image, color = iconColor) + Spacer(Modifier.padding(horizontal = 8.dp)) + Column(Modifier.height(size), verticalArrangement = Arrangement.Center) { + Text( + profileOf.displayName, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + color = if (stopped) MaterialTheme.colors.secondary else textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (profileOf.fullName.isNotEmpty()) { + Text( + profileOf.fullName, + Modifier.padding(vertical = 5.dp), + color = if (stopped) MaterialTheme.colors.secondary else textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false) { + SectionItemView(click, disabled = disabled, extraPadding = extraPadding) { + Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) + TextIconSpaced(extraPadding) + Text(text, color = if (disabled) MaterialTheme.colors.secondary else textColor) + } +} + +@Composable +fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) { + SectionItemView( + click, + extraPadding = extraPadding, + padding = if (extraPadding && icon != null) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + else + PaddingValues(horizontal = DEFAULT_PADDING), + disabled = disabled + ) { + if (icon != null) { + Icon(icon, text, Modifier, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) + TextIconSpaced(extraPadding) + } + if (text != null) { + Text(text, Modifier.weight(1f), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground) + Spacer(Modifier.width(DEFAULT_PADDING)) + } + content() + } +} + +@Composable +fun SettingsPreferenceItem( + icon: Painter?, + text: String, + pref: SharedPreference, + iconColor: Color = MaterialTheme.colors.secondary, + enabled: Boolean = true, + onChange: ((Boolean) -> Unit)? = null, +) { + SettingsActionItemWithContent(icon, text, iconColor = iconColor,) { + SharedPreferenceToggle(pref, enabled, onChange) + } +} + +@Composable +fun SettingsPreferenceItemWithInfo( + icon: Painter, + iconTint: Color, + text: String, + stopped: Boolean, + onClickInfo: () -> Unit, + pref: SharedPreference, + prefState: MutableState? = null +) { + SettingsActionItemWithContent(icon, null, click = if (stopped) null else onClickInfo, iconColor = iconTint, extraPadding = true,) { + SharedPreferenceToggleWithIcon(text, painterResource(MR.images.ic_info), stopped, onClickInfo, pref, prefState) + } +} + +@Composable +fun PreferenceToggle( + text: String, + checked: Boolean, + onChange: (Boolean) -> Unit = {}, +) { + SettingsActionItemWithContent(null, text, extraPadding = true,) { + DefaultSwitch( + checked = checked, + onCheckedChange = onChange, + ) + } +} + +@Composable +fun PreferenceToggleWithIcon( + text: String, + icon: Painter? = null, + iconColor: Color? = MaterialTheme.colors.secondary, + checked: Boolean, + extraPadding: Boolean = false, + onChange: (Boolean) -> Unit = {}, +) { + SettingsActionItemWithContent(icon, text, iconColor = iconColor ?: MaterialTheme.colors.secondary, extraPadding = extraPadding) { + DefaultSwitch( + checked = checked, + onCheckedChange = { + onChange(it) + }, + ) + } +} + +private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) { + authenticate( + title, + desc, + completed = { laResult -> + onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable) + } + ) +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PreviewSettingsLayout() { + SimpleXTheme { + SettingsLayout( + profile = LocalProfile.sampleData, + stopped = false, + encrypted = false, + incognito = remember { mutableStateOf(false) }, + incognitoPref = SharedPreference({ false }, {}), + userDisplayName = "Alice", + setPerformLA = { _ -> }, + showModal = { {} }, + showSettingsModal = { {} }, + showSettingsModalWithSearch = { }, + showCustomModal = { {} }, + showVersion = {}, + withAuth = { _, _, _ -> }, + ) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index b033f69f38..4b39102f97 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -1,495 +1,33 @@ package chat.simplex.app.views.usersettings -import SectionBottomSpacer -import SectionDividerSpaced -import SectionItemView -import SectionItemViewWithIcon import SectionView -import TextIconSpaced -import android.content.res.Configuration -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.* -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.* -import androidx.fragment.app.FragmentActivity +import androidx.compose.runtime.Composable import androidx.work.WorkManager -import chat.simplex.app.* -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.database.DatabaseView -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.onboarding.SimpleXInfo -import chat.simplex.app.views.onboarding.WhatsNewView +import chat.simplex.app.SimplexApp +import chat.simplex.app.SimplexService +import chat.simplex.app.model.ChatModel +import chat.simplex.app.views.helpers.AlertManager +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.res.MR import com.jakewharton.processphoenix.ProcessPhoenix +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource @Composable -fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { - val user = chatModel.currentUser.value - val stopped = chatModel.chatRunning.value == false - - MaintainIncognitoState(chatModel) - - if (user != null) { - val requireAuth = remember { chatModel.controller.appPrefs.performLA.state } - SettingsLayout( - profile = user.profile, - stopped, - chatModel.chatDbEncrypted.value == true, - chatModel.incognito, - chatModel.controller.appPrefs.incognito, - user.displayName, - setPerformLA = setPerformLA, - showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } }, - showSettingsModalWithSearch = { modalView -> - ModalManager.shared.showCustomModal { close -> - val search = rememberSaveable { mutableStateOf("") } - ModalView( - { close() }, - endButtons = { - SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = true) { search.value = it } - }, - content = { modalView(chatModel, search) }) - } - }, - showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, - showVersion = { - withApi { - val info = chatModel.controller.apiGetVersion() - if (info != null) { - ModalManager.shared.showModal { VersionInfoView(info) } - } - } - }, - withAuth = { title, desc, block -> - if (!requireAuth.value) { - block() - } else { - var autoShow = true - ModalManager.shared.showModalCloseable { close -> - val onFinishAuth = { success: Boolean -> - if (success) { - close() - block() - } - } - - LaunchedEffect(Unit) { - if (autoShow) { - autoShow = false - runAuth(title, desc, onFinishAuth) - } - } - Box( - Modifier.fillMaxSize().background(MaterialTheme.colors.background), - contentAlignment = Alignment.Center - ) { - SimpleButton( - stringResource(MR.strings.auth_unlock), - icon = painterResource(MR.images.ic_lock), - click = { - runAuth(title, desc, onFinishAuth) - } - ) - } - } - } - }, - ) - } -} - -val simplexTeamUri = - "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" - -@Composable -fun SettingsLayout( - profile: LocalProfile, - stopped: Boolean, - encrypted: Boolean, - incognito: MutableState, - incognitoPref: SharedPreference, - userDisplayName: String, - setPerformLA: (Boolean) -> Unit, - showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), +fun SettingsSectionApp( showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - showSettingsModalWithSearch: (@Composable (ChatModel, MutableState) -> Unit) -> Unit, showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showVersion: () -> Unit, withAuth: (title: String, desc: String, block: () -> Unit) -> Unit ) { - val theme = CurrentColors.collectAsState() - val uriHandler = LocalUriHandler.current - Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).themedBackground(theme.value.base)) { - Column( - Modifier - .fillMaxSize() - .padding(top = DEFAULT_PADDING) - ) { - AppBarTitle(stringResource(MR.strings.your_settings)) - - SectionView(stringResource(MR.strings.settings_section_title_you)) { - SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) { - ProfilePreview(profile, stopped = stopped) - } - val profileHidden = rememberSaveable { mutableStateOf(false) } - SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) - SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() } - SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) - ChatPreferencesItem(showCustomModal, stopped = stopped) - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_settings)) { - SettingsActionItem(painterResource(MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView(it, showModal, showSettingsModal, showCustomModal) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it, showSettingsModal) }, extraPadding = true) - DatabaseItem(encrypted, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_help)) { - SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openUriCatching(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true) - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_support)) { - ContributeItem(uriHandler) - RateAppItem(uriHandler) - StarOnGithubItem(uriHandler) - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_app)) { - SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) }, extraPadding = true) - SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }, extraPadding = true) - AppVersionItem(showVersion) - } - SectionBottomSpacer() - } + SectionView(stringResource(MR.strings.settings_section_title_app)) { + SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) }, extraPadding = true) + SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }, extraPadding = true) + AppVersionItem(showVersion) } } -@Composable -fun SettingsIncognitoActionItem( - incognitoPref: SharedPreference, - incognito: MutableState, - stopped: Boolean, - onClickInfo: () -> Unit, -) { - SettingsPreferenceItemWithInfo( - if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), - if (incognito.value) Indigo else MaterialTheme.colors.secondary, - stringResource(MR.strings.incognito), - stopped, - onClickInfo, - incognitoPref, - incognito - ) -} - -@Composable -fun MaintainIncognitoState(chatModel: ChatModel) { - // Cache previous value and once it changes in background, update it via API - var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) } - LaunchedEffect(chatModel.incognito.value) { - // Don't do anything if nothing changed - if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect - try { - chatModel.controller.apiSetIncognito(chatModel.incognito.value) - } catch (e: Exception) { - // Rollback the state - chatModel.controller.appPrefs.incognito.set(cachedIncognito) - // Crash the app - throw e - } - cachedIncognito = chatModel.incognito.value - } -} - -@Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { - SectionItemViewWithIcon(openDatabaseView) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(Modifier.weight(1f)) { - Icon( - painterResource(MR.images.ic_database), - contentDescription = stringResource(MR.strings.database_passphrase_and_export), - tint = if (encrypted) MaterialTheme.colors.secondary else WarningOrange, - ) - TextIconSpaced(true) - Text(stringResource(MR.strings.database_passphrase_and_export)) - } - if (stopped) { - Icon( - painterResource(MR.images.ic_report_filled), - contentDescription = stringResource(MR.strings.chat_is_stopped), - tint = Color.Red, - modifier = Modifier.padding(end = 6.dp) - ) - } - } - } -} - -@Composable fun ChatPreferencesItem(showCustomModal: ((@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit)), stopped: Boolean) { - SettingsActionItem( - painterResource(MR.images.ic_toggle_on), - stringResource(MR.strings.chat_preferences), - click = if (stopped) null else ({ - withApi { - showCustomModal { m, close -> - PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close) - }() - } - }), - disabled = stopped, - extraPadding = true - ) -} - -@Composable -fun ChatLockItem( - chatModel: ChatModel, - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - setPerformLA: (Boolean) -> Unit -) { - val performLA = remember { chatModel.performLA } - val currentLAMode = remember { chatModel.controller.appPrefs.laMode } - SettingsActionItemWithContent( - click = showSettingsModal { SimplexLockView(chatModel, currentLAMode, setPerformLA) }, - icon = if (performLA.value) painterResource(MR.images.ic_lock_filled) else painterResource(MR.images.ic_lock), - text = stringResource(MR.strings.chat_lock), - iconColor = if (performLA.value) SimplexGreen else MaterialTheme.colors.secondary, - extraPadding = false, - ) { - Text(if (performLA.value) remember { currentLAMode.state }.value.text else generalGetString(MR.strings.la_mode_off), color = MaterialTheme.colors.secondary) - } -} - -@Composable private fun ContributeItem(uriHandler: UriHandler) { - SectionItemViewWithIcon({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat#contribute") }) { - Icon( - painterResource(MR.images.ic_keyboard), - contentDescription = "GitHub", - tint = MaterialTheme.colors.secondary, - ) - TextIconSpaced(extraPadding = true) - Text(generalGetString(MR.strings.contribute), color = MaterialTheme.colors.primary) - } -} - -@Composable private fun RateAppItem(uriHandler: UriHandler) { - SectionItemViewWithIcon({ - runCatching { uriHandler.openUriCatching("market://details?id=chat.simplex.app") } - .onFailure { uriHandler.openUriCatching("https://play.google.com/store/apps/details?id=chat.simplex.app") } - } - ) { - Icon( - painterResource(MR.images.ic_star), - contentDescription = "Google Play", - tint = MaterialTheme.colors.secondary, - ) - TextIconSpaced(extraPadding = true) - Text(generalGetString(MR.strings.rate_the_app), color = MaterialTheme.colors.primary) - } -} - -@Composable private fun StarOnGithubItem(uriHandler: UriHandler) { - SectionItemViewWithIcon({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { - Icon( - painter = painterResource(MR.images.ic_github), - contentDescription = "GitHub", - tint = MaterialTheme.colors.secondary, - ) - TextIconSpaced(extraPadding = true) - Text(generalGetString(MR.strings.star_on_github), color = MaterialTheme.colors.primary) - } -} - -@Composable fun ChatConsoleItem(showTerminal: () -> Unit) { - SectionItemView(showTerminal) { - Icon( - painter = painterResource(MR.images.ic_outline_terminal), - contentDescription = stringResource(MR.strings.chat_console), - tint = MaterialTheme.colors.secondary, - ) - TextIconSpaced() - Text(stringResource(MR.strings.chat_console)) - } -} - -@Composable fun InstallTerminalAppItem(uriHandler: UriHandler) { - SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) { - Icon( - painter = painterResource(MR.images.ic_github), - contentDescription = "GitHub", - tint = MaterialTheme.colors.secondary, - ) - TextIconSpaced() - Text(generalGetString(MR.strings.install_simplex_chat_for_terminal), color = MaterialTheme.colors.primary) - } -} - -@Composable private fun AppVersionItem(showVersion: () -> Unit) { - SectionItemViewWithIcon(showVersion) { AppVersionText() } -} - -@Composable fun AppVersionText() { - Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") -} - -@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) { - ProfileImage(size = size, image = profileOf.image, color = iconColor) - Spacer(Modifier.padding(horizontal = 8.dp)) - Column(Modifier.height(size), verticalArrangement = Arrangement.Center) { - Text( - profileOf.displayName, - style = MaterialTheme.typography.caption, - fontWeight = FontWeight.Bold, - color = if (stopped) MaterialTheme.colors.secondary else textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (profileOf.fullName.isNotEmpty()) { - Text( - profileOf.fullName, - Modifier.padding(vertical = 5.dp), - color = if (stopped) MaterialTheme.colors.secondary else textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } -} - -@Composable -fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false) { - SectionItemView(click, disabled = disabled, extraPadding = extraPadding) { - Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) - TextIconSpaced(extraPadding) - Text(text, color = if (disabled) MaterialTheme.colors.secondary else textColor) - } -} - -@Composable -fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) { - SectionItemView( - click, - extraPadding = extraPadding, - padding = if (extraPadding && icon != null) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) - else - PaddingValues(horizontal = DEFAULT_PADDING), - disabled = disabled - ) { - if (icon != null) { - Icon(icon, text, Modifier, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) - TextIconSpaced(extraPadding) - } - if (text != null) { - Text(text, Modifier.weight(1f), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.onBackground) - Spacer(Modifier.width(DEFAULT_PADDING)) - } - content() - } -} - -@Composable -fun SettingsPreferenceItem( - icon: Painter?, - text: String, - pref: SharedPreference, - iconColor: Color = MaterialTheme.colors.secondary, - enabled: Boolean = true, - onChange: ((Boolean) -> Unit)? = null, -) { - SettingsActionItemWithContent(icon, text, iconColor = iconColor,) { - SharedPreferenceToggle(pref, enabled, onChange) - } -} - -@Composable -fun SettingsPreferenceItemWithInfo( - icon: Painter, - iconTint: Color, - text: String, - stopped: Boolean, - onClickInfo: () -> Unit, - pref: SharedPreference, - prefState: MutableState? = null -) { - SettingsActionItemWithContent(icon, null, click = if (stopped) null else onClickInfo, iconColor = iconTint, extraPadding = true,) { - SharedPreferenceToggleWithIcon(text, painterResource(MR.images.ic_info), stopped, onClickInfo, pref, prefState) - } -} - -@Composable -fun PreferenceToggle( - text: String, - checked: Boolean, - onChange: (Boolean) -> Unit = {}, -) { - SettingsActionItemWithContent(null, text, extraPadding = true,) { - DefaultSwitch( - checked = checked, - onCheckedChange = onChange, - ) - } -} - -@Composable -fun PreferenceToggleWithIcon( - text: String, - icon: Painter? = null, - iconColor: Color? = MaterialTheme.colors.secondary, - checked: Boolean, - extraPadding: Boolean = false, - onChange: (Boolean) -> Unit = {}, -) { - SettingsActionItemWithContent(icon, text, iconColor = iconColor ?: MaterialTheme.colors.secondary, extraPadding = extraPadding) { - DefaultSwitch( - checked = checked, - onCheckedChange = { - onChange(it) - }, - ) - } -} - -private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> Unit) { - authenticate( - title, - desc, - completed = { laResult -> - onFinish(laResult == LAResult.Success || laResult is LAResult.Unavailable) - } - ) -} - private fun restartApp() { ProcessPhoenix.triggerRebirth(SimplexApp.context) shutdownApp() @@ -509,30 +47,3 @@ private fun shutdownAppAlert(onConfirm: () -> Unit) { onConfirm = onConfirm ) } - -@Preview(showBackground = true) -@Preview( - uiMode = Configuration.UI_MODE_NIGHT_YES, - showBackground = true, - name = "Dark Mode" -) -@Composable -fun PreviewSettingsLayout() { - SimpleXTheme { - SettingsLayout( - profile = LocalProfile.sampleData, - stopped = false, - encrypted = false, - incognito = remember { mutableStateOf(false) }, - incognitoPref = SharedPreference({ false }, {}), - userDisplayName = "Alice", - setPerformLA = { _ -> }, - showModal = { {} }, - showSettingsModal = { {} }, - showSettingsModalWithSearch = { }, - showCustomModal = { {} }, - showVersion = {}, - withAuth = { _, _, _ -> }, - ) - } -} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt index 2cd8484296..9b60ff465c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt @@ -20,9 +20,10 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.* +import chat.simplex.app.platform.sendEmail +import chat.simplex.app.platform.shareText import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.ShareAddressButton import chat.simplex.app.views.helpers.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index ee03a689ec..9a4dca4125 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R import chat.simplex.app.model.* +import chat.simplex.app.platform.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.ProfileNameField import chat.simplex.app.views.helpers.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt index 49024f5977..db3174ad9f 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.app.R -import chat.simplex.app.chatPasswordHash import chat.simplex.app.model.* +import chat.simplex.app.platform.chatPasswordHash import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.chatlist.UserProfilePickerItem diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 9a3c9c48ab..8b2977a3e8 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -19,7 +19,7 @@ extern void __rel_iplt_start(void){}; extern void reallocarray(void){}; JNIEXPORT jint JNICALL -Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) { +Java_chat_simplex_app_platform_Backend_1commonKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) { const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE); int ret = pipe_std_to_socket(name); (*env)->ReleaseStringUTFChars(env, socket_name, name); @@ -27,7 +27,7 @@ Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jcla } JNIEXPORT void JNICALL -Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) { +Java_chat_simplex_app_platform_Backend_1commonKt_initHS(__unused JNIEnv *env, __unused jclass clazz) { hs_init(NULL, NULL); setLineBuffering(); } @@ -44,7 +44,7 @@ extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); JNIEXPORT jobjectArray JNICALL -Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) { +Java_chat_simplex_app_platform_Backend_1commonKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) { const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE); const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE); const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE); @@ -67,7 +67,7 @@ Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass } JNIEXPORT jstring JNICALL -Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) { +Java_chat_simplex_app_platform_Backend_1commonKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) { const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg)); (*env)->ReleaseStringUTFChars(env, msg, _msg); @@ -75,17 +75,17 @@ Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass claz } JNIEXPORT jstring JNICALL -Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { +Java_chat_simplex_app_platform_Backend_1commonKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); } JNIEXPORT jstring JNICALL -Java_chat_simplex_app_SimplexAppKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) { +Java_chat_simplex_app_platform_Backend_1commonKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) { return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait)); } JNIEXPORT jstring JNICALL -Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) { +Java_chat_simplex_app_platform_Backend_1commonKt_chatParseMarkdown(JNIEnv *env, __unused jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); jstring res = (*env)->NewStringUTF(env, chat_parse_markdown(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); @@ -93,7 +93,7 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas } JNIEXPORT jstring JNICALL -Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) { +Java_chat_simplex_app_platform_Backend_1commonKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); @@ -101,7 +101,7 @@ Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass } JNIEXPORT jstring JNICALL -Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) { +Java_chat_simplex_app_platform_Backend_1commonKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) { const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE); const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE); jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));