diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 5d54c5f7b6..a1b90e48d9 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -7,10 +7,6 @@ plugins { id("org.jetbrains.kotlin.plugin.serialization") } -repositories { - maven("https://jitpack.io") -} - android { compileSdkVersion(33) @@ -124,9 +120,16 @@ dependencies { //implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}") //implementation("androidx.compose.material:material:$compose_version") //implementation("androidx.compose.ui:ui-tooling-preview:$compose_version") + implementation("androidx.appcompat:appcompat:1.5.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") implementation("androidx.lifecycle:lifecycle-process:2.4.1") implementation("androidx.activity:activity-compose:1.5.0") + val work_version = "2.7.1" + implementation("androidx.work:work-runtime-ktx:$work_version") + implementation("androidx.work:work-multiprocess:$work_version") + + implementation("com.jakewharton:process-phoenix:2.1.2") + //implementation("androidx.compose.material:material-icons-extended:$compose_version") //implementation("androidx.compose.ui:ui-util:$compose_version") diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/BackupAgent.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/BackupAgent.kt index bbe4e8318c..417dfbbaa0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/BackupAgent.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/BackupAgent.kt @@ -3,8 +3,8 @@ package chat.simplex.app import android.app.backup.BackupAgentHelper import android.app.backup.FullBackupDataOutput import android.content.Context -import chat.simplex.app.model.AppPreferences -import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP +import chat.simplex.common.model.AppPreferences +import chat.simplex.common.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP class BackupAgent: BackupAgentHelper() { override fun onFullBackup(data: FullBackupDataOutput?) { 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 090cb3b217..b8c517ddfa 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 @@ -1,84 +1,40 @@ package chat.simplex.app -import android.app.Application import android.content.Intent import android.net.Uri import android.os.* -import android.os.SystemClock.elapsedRealtime -import android.util.Log import android.view.WindowManager import androidx.activity.compose.setContent -import androidx.activity.viewModels -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.model.* +import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.getUserIdFromIntent -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.SetDeliveryReceiptsView +import chat.simplex.common.* +import chat.simplex.common.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.* +import chat.simplex.common.platform.* 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?) { applyAppLocale(ChatModel.controller.appPrefs.appLanguage) super.onCreate(savedInstanceState) - SimplexApp.context.mainActivity = WeakReference(this) // testJson() - val m = vm.chatModel + mainActivity = WeakReference(this) // When call ended and orientation changes, it re-process old intent, it's unneeded. // Only needed to be processed on first creation of activity if (savedInstanceState == null) { - processNotificationIntent(intent, m) - processIntent(intent, m) - processExternalIntent(intent, m) + processNotificationIntent(intent) + processIntent(intent) + processExternalIntent(intent) } - if (m.controller.appPrefs.privacyProtectScreen.get()) { + if (ChatController.appPrefs.privacyProtectScreen.get()) { Log.d(TAG, "onCreate: set FLAG_SECURE") window.setFlags( WindowManager.LayoutParams.FLAG_SECURE, @@ -87,17 +43,7 @@ class MainActivity: FragmentActivity() { } setContent { SimpleXTheme { - Surface(color = MaterialTheme.colors.background) { - MainPage( - m, - userAuthorized, - laFailed, - destroyedAfterBackPress, - ::runAuthenticate, - ::setPerformLA, - showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown) } - ) - } + AppScreen() } } SimplexApp.context.schedulePeriodicServiceRestartWorker() @@ -106,38 +52,29 @@ class MainActivity: FragmentActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - processIntent(intent, vm.chatModel) - processExternalIntent(intent, vm.chatModel) + processIntent(intent) + processExternalIntent(intent) } override fun onResume() { super.onResume() - val enteredBackgroundVal = enteredBackground.value - val delay = vm.chatModel.controller.appPrefs.laLockDelay.get() - if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) { - if (userAuthorized.value != false) { - /** [runAuthenticate] will be called in [MainPage] if needed. Making like this prevents double showing of passcode on start */ - setAuthState() - } else if (!vm.chatModel.activeCallViewIsVisible.value) { - runAuthenticate() - } - } + AppLock.recheckAuthState() } 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.appWasHidden() } override fun onStop() { super.onStop() VideoPlayer.stopAll() - enteredBackground.value = elapsedRealtime() + AppLock.appWasHidden() } override fun onBackPressed() { @@ -150,428 +87,20 @@ class MainActivity: FragmentActivity() { super.onBackPressed() } - if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) { + if (!onBackPressedDispatcher.hasEnabledCallbacks() && ChatController.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) { - val app = getApplication() - 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() - } - ) { - if (chatModel.setDeliveryReceipts.value) { - SetDeliveryReceiptsView(chatModel) - } else { - 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) { +fun processNotificationIntent(intent: Intent?) { val userId = getUserIdFromIntent(intent) when (intent?.action) { NtfManager.OpenChatAction -> { @@ -615,16 +144,16 @@ fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) { } } -fun processIntent(intent: Intent?, chatModel: ChatModel) { +fun processIntent(intent: Intent?) { when (intent?.action) { "android.intent.action.VIEW" -> { val uri = intent.data - if (uri != null) connectIfOpenedViaUri(uri, chatModel) + if (uri != null) connectIfOpenedViaUri(uri.toURI(), ChatModel) } } } -fun processExternalIntent(intent: Intent?, chatModel: ChatModel) { +fun processExternalIntent(intent: Intent?) { when (intent?.action) { Intent.ACTION_SEND -> { // Close active chat and show a list of chats @@ -640,13 +169,13 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) { isMediaIntent(intent) -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { - chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri)) + chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI())) } // All other mime types } else -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { - chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri) + chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI()) } } } @@ -660,7 +189,7 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) { isMediaIntent(intent) -> { val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) as? List if (uris != null) { - chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris) + chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() }) } // All other mime types } else -> {} @@ -672,35 +201,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/views/helpers/MessagesFetcherWorker.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt similarity index 94% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/MessagesFetcherWorker.kt rename to apps/multiplatform/android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt index 5b18ff5220..3b8902b58c 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/MessagesFetcherWorker.kt @@ -1,10 +1,13 @@ -package chat.simplex.app.views.helpers +package chat.simplex.app import android.content.Context import android.util.Log import androidx.work.* import chat.simplex.app.* import chat.simplex.app.SimplexService.Companion.showPassphraseNotification +import chat.simplex.common.model.ChatController +import chat.simplex.common.views.helpers.DBMigrationResult +import chat.simplex.app.BuildConfig import kotlinx.coroutines.* import java.util.Date import java.util.concurrent.TimeUnit @@ -55,7 +58,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/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 209e1f4093..3174e06265 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 @@ -1,101 +1,31 @@ package chat.simplex.app import android.app.Application -import android.net.LocalServerSocket -import android.util.Log +import chat.simplex.common.platform.Log import androidx.lifecycle.* import androidx.work.* -import chat.simplex.app.model.* -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 chat.simplex.app.model.NtfManager +import chat.simplex.common.helpers.APPLICATION_ID +import chat.simplex.common.helpers.requiresIgnoringBattery +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.platform.* +import chat.simplex.common.views.call.RcvCallInvitation import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* -import kotlinx.serialization.decodeFromString import java.io.* -import java.lang.ref.WeakReference import java.util.* -import java.util.concurrent.Semaphore import java.util.concurrent.TimeUnit -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.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) - 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 - } - if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { - chatModel.setDeliveryReceipts.value = true - } - 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() @@ -103,7 +33,10 @@ class SimplexApp: Application(), LifecycleEventObserver { return; } context = this - context.getDir("temp", MODE_PRIVATE).deleteRecursively() + initHaskell() + initMultiplatform() + tmpDir.deleteRecursively() + withBGApi { initChatController() runMigrations() @@ -147,7 +80,7 @@ class SimplexApp: Application(), LifecycleEventObserver { * */ if (chatModel.chatRunning.value != false && chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && - appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name + appPrefs.notificationsMode.get() == NotificationsMode.SERVICE ) { SimplexService.start() } @@ -158,12 +91,12 @@ class SimplexApp: Application(), LifecycleEventObserver { } fun allowToStartServiceAfterAppExit() = with(chatModel.controller) { - appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name && + appPrefs.notificationsMode.get() == NotificationsMode.SERVICE && (!NotificationsMode.SERVICE.requiresIgnoringBattery || SimplexService.isIgnoringBatteryOptimizations()) } private fun allowToStartPeriodically() = with(chatModel.controller) { - appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC.name && + appPrefs.notificationsMode.get() == NotificationsMode.PERIODIC && (!NotificationsMode.PERIODIC.requiresIgnoringBattery || SimplexService.isIgnoringBatteryOptimizations()) } @@ -198,75 +131,76 @@ 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()) - } + private fun initMultiplatform() { + androidAppContext = this + APPLICATION_ID = BuildConfig.APPLICATION_ID + ntfManager = object : chat.simplex.common.platform.NtfManager() { + override fun notifyContactConnected(user: User, contact: Contact) = NtfManager.notifyContactConnected(user, contact) + override fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) = NtfManager.notifyContactRequestReceived(user, cInfo) + override fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) = NtfManager.notifyMessageReceived(user, cInfo, cItem) + override fun notifyCallInvitation(invitation: RcvCallInvitation) = NtfManager.notifyCallInvitation(invitation) + override fun hasNotificationsForChat(chatId: String): Boolean = NtfManager.hasNotificationsForChat(chatId) + override fun cancelNotificationsForChat(chatId: String) = NtfManager.cancelNotificationsForChat(chatId) + override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List) = NtfManager.displayNotification(user, chatId, displayName, msgText, image, actions) + override fun createNtfChannelsMaybeShowAlert() = NtfManager.createNtfChannelsMaybeShowAlert() + override fun cancelCallNotification() = NtfManager.cancelCallNotification() + override fun cancelAllNotifications() = NtfManager.cancelAllNotifications() + } + platform = object : PlatformInterface { + override suspend fun androidServiceStart() { + SimplexService.start() + } + + override fun androidServiceSafeStop() { + SimplexService.safeStopService() + } + + override fun androidNotificationsModeChanged(mode: NotificationsMode) { + if (mode.requiresIgnoringBattery && !SimplexService.isIgnoringBatteryOptimizations()) { + appPrefs.backgroundServiceNoticeShown.set(false) } - if (server == null) { - throw Error("Unable to setup local server socket. Contact developers") + SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) + CoroutineScope(Dispatchers.Default).launch { + if (mode == NotificationsMode.SERVICE) + SimplexService.start() + else + SimplexService.safeStopService() } - 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") + + if (mode != NotificationsMode.PERIODIC) { + MessagesFetcherWorker.cancelAll() + } + SimplexService.showBackgroundServiceNoticeIfNeeded() + } + + override fun androidChatStartedAfterBeingOff() { + SimplexService.cancelPassphraseNotification() + when (appPrefs.notificationsMode.get()) { + NotificationsMode.SERVICE -> CoroutineScope(Dispatchers.Default).launch { platform.androidServiceStart() } + NotificationsMode.PERIODIC -> SimplexApp.context.schedulePeriodicWakeUp() + NotificationsMode.OFF -> {} } } - System.loadLibrary("app-lib") + override fun androidChatStopped() { + SimplexService.safeStopService() + MessagesFetcherWorker.cancelAll() + } - s.acquire() - pipeStdOutToSocket(socketName) - - initHS() + override fun androidChatInitializedAndStarted() { + // Prevents from showing "Enable notifications" alert when onboarding wasn't complete yet + if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) { + SimplexService.showBackgroundServiceNoticeIfNeeded() + if (appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) + withBGApi { + platform.androidServiceStart() + } + } + } } } } - -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..8624daa5e0 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 @@ -7,23 +7,26 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.* import android.provider.Settings -import android.util.Log import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.Log 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.views.helpers.* -import chat.simplex.app.views.usersettings.NotificationsMode +import chat.simplex.common.AppLock +import chat.simplex.common.AppLock.clearAuthState +import chat.simplex.common.helpers.requiresIgnoringBattery +import chat.simplex.common.model.ChatController +import chat.simplex.common.model.NotificationsMode +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.views.helpers.* +import kotlinx.coroutines.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.* // based on: // https://robertohuertas.com/2019/06/29/android_foreground_services/ @@ -97,7 +100,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") @@ -105,7 +108,7 @@ class SimplexService: Service() { if (chatDbStatus != DBMigrationResult.OK) { Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus") showPassphraseNotification(chatDbStatus) - safeStopService(self) + safeStopService() return@withApi } saveServiceState(self, ServiceState.STARTED) @@ -167,7 +170,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()) { @@ -265,9 +268,9 @@ class SimplexService: Service() { * If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an * exception related to foreground services lifecycle * */ - fun safeStopService(context: Context) { + fun safeStopService() { if (isServiceStarted) { - context.stopService(Intent(context, SimplexService::class.java)) + androidAppContext.stopService(Intent(androidAppContext, SimplexService::class.java)) } else { stopAfterStart = true } @@ -276,9 +279,9 @@ class SimplexService: Service() { private suspend fun serviceAction(action: Action) { Log.d(TAG, "SimplexService serviceAction: ${action.name}") withContext(Dispatchers.IO) { - Intent(SimplexApp.context, SimplexService::class.java).also { + Intent(androidAppContext, SimplexService::class.java).also { it.action = action.name - ContextCompat.startForegroundService(SimplexApp.context, it) + ContextCompat.startForegroundService(androidAppContext, it) } } } @@ -352,7 +355,7 @@ class SimplexService: Service() { fun showBackgroundServiceNoticeIfNeeded() { val appPrefs = ChatController.appPrefs - val mode = NotificationsMode.valueOf(appPrefs.notificationsMode.get()!!) + val mode = appPrefs.notificationsMode.get() Log.d(TAG, "showBackgroundServiceNoticeIfNeeded") // Nothing to do if mode is OFF. Can be selected on on-boarding stage if (mode == NotificationsMode.OFF) return @@ -373,11 +376,10 @@ class SimplexService: Service() { if (appPrefs.backgroundServiceBatteryNoticeShown.get()) { // users have been presented with battery notice before - they did not allow ignoring optimizations -> disable service showDisablingServiceNotice(mode) - appPrefs.notificationsMode.set(NotificationsMode.OFF.name) - ChatModel.notificationsMode.value = NotificationsMode.OFF - SimplexService.StartReceiver.toggleReceiver(false) + appPrefs.notificationsMode.set(NotificationsMode.OFF) + StartReceiver.toggleReceiver(false) MessagesFetcherWorker.cancelAll() - SimplexService.safeStopService(SimplexApp.context) + safeStopService() } else { // show battery optimization notice showBGServiceNoticeIgnoreOptimization(mode) @@ -489,18 +491,18 @@ class SimplexService: Service() { } fun isIgnoringBatteryOptimizations(): Boolean { - val powerManager = SimplexApp.context.getSystemService(Application.POWER_SERVICE) as PowerManager - return powerManager.isIgnoringBatteryOptimizations(SimplexApp.context.packageName) + val powerManager = androidAppContext.getSystemService(Application.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(androidAppContext.packageName) } private fun askAboutIgnoringBatteryOptimization() { Intent().apply { @SuppressLint("BatteryLife") action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = Uri.parse("package:${SimplexApp.context.packageName}") + data = Uri.parse("package:${androidAppContext.packageName}") // This flag is needed when you start a new activity from non-Activity context addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - SimplexApp.context.startActivity(this) + androidAppContext.startActivity(this) } } } 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..5752d3fd6b 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 @@ -1,6 +1,5 @@ package chat.simplex.app.model -import android.Manifest import android.app.* import android.app.TaskStackBuilder import android.content.* @@ -9,16 +8,21 @@ import android.graphics.BitmapFactory import android.hardware.display.DisplayManager import android.media.AudioAttributes import android.net.Uri -import android.util.Log import android.view.Display +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.core.app.* import chat.simplex.app.* -import chat.simplex.app.views.call.* -import chat.simplex.app.views.chatlist.acceptContactRequest -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.NotificationPreviewMode -import chat.simplex.res.MR +import chat.simplex.app.TAG +import chat.simplex.app.views.call.IncomingCallActivity +import chat.simplex.app.views.call.getKeyguardManager +import chat.simplex.common.views.chatlist.acceptContactRequest +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.call.RcvCallInvitation import kotlinx.datetime.Clock +import chat.simplex.res.MR object NtfManager { const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION" @@ -33,7 +37,7 @@ object NtfManager { const val CallNotificationId: Int = -1 private const val UserIdKey: String = "userId" private const val ChatIdKey: String = "chatId" - private val appPreferences: AppPreferences by lazy { ChatController.appPrefs } + private val appPreferences: AppPreferences = ChatController.appPrefs private val context: Context get() = SimplexApp.context @@ -42,7 +46,7 @@ object NtfManager { return if (userId == -1L || userId == null) null else userId } - private val manager: NotificationManager = SimplexApp.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private var prevNtfTime = mutableMapOf() private val msgNtfTimeoutMs = 30000L @@ -50,10 +54,6 @@ object NtfManager { if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert() } - enum class NotificationAction { - ACCEPT_CONTACT_REQUEST - } - private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel { val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH) val attrs = AudioAttributes.Builder() @@ -119,7 +119,7 @@ object NtfManager { val largeIcon = when { actions.isEmpty() -> null image == null || previewMode == NotificationPreviewMode.HIDDEN.name -> BitmapFactory.decodeResource(context.resources, R.drawable.icon) - else -> base64ToBitmap(image) + else -> base64ToBitmap(image).asAndroidBitmap() } val builder = NotificationCompat.Builder(context, MessageChannel) .setContentTitle(title) @@ -158,7 +158,7 @@ object NtfManager { with(NotificationManagerCompat.from(context)) { // using cInfo.id only shows one notification per chat and updates it when the message arrives - if (ActivityCompat.checkSelfPermission(SimplexApp.context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { notify(chatId.hashCode(), builder.build()) notify(0, summary) } @@ -172,9 +172,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 @@ -212,7 +212,7 @@ object NtfManager { val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name) BitmapFactory.decodeResource(context.resources, R.drawable.icon) else - base64ToBitmap(image) + base64ToBitmap(image).asAndroidBitmap() ntfBuilder = ntfBuilder .setContentTitle(title) @@ -227,7 +227,7 @@ object NtfManager { // This makes notification sound and vibration repeat endlessly notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT with(NotificationManagerCompat.from(context)) { - if (ActivityCompat.checkSelfPermission(SimplexApp.context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + if (ActivityCompat.checkSelfPermission(SimplexApp.context, android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { notify(CallNotificationId, notification) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt index d2ffc507e0..104b654c58 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallActivity.kt @@ -4,16 +4,14 @@ import android.app.Activity import android.app.KeyguardManager import android.content.Context import android.content.Intent -import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.util.Log +import chat.simplex.common.platform.Log import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.viewModels +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -24,27 +22,27 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.res.painterResource 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.* import chat.simplex.app.R -import chat.simplex.app.model.* +import chat.simplex.common.model.* import chat.simplex.app.model.NtfManager.OpenChatAction -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.ProfileImage +import chat.simplex.common.platform.ntfManager +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.* +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock class IncomingCallActivity: ComponentActivity() { - private val vm by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { IncomingCallActivityView(vm.chatModel) } + setContent { IncomingCallActivityView(ChatModel) } unlockForIncomingCall() } @@ -103,7 +101,7 @@ fun IncomingCallActivityView(m: ChatModel) { ) { if (showCallView) { Box { - ActiveCallView(m) + ActiveCallView() if (invitation != null) IncomingCallAlertView(invitation, m) } } else if (invitation != null) { @@ -121,7 +119,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo DisposableEffect(Unit) { onDispose { // Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist - chatModel.controller.ntfManager.cancelCallNotification() + ntfManager.cancelCallNotification() } } IncomingCallLockScreenAlertLayout( @@ -131,7 +129,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo rejectCall = { cm.endCall(invitation = invitation) }, ignoreCall = { chatModel.activeCallInvitation.value = null - chatModel.controller.ntfManager.cancelCallNotification() + ntfManager.cancelCallNotification() }, acceptCall = { cm.acceptIncomingCall(invitation = invitation) }, openApp = { @@ -171,18 +169,18 @@ fun IncomingCallLockScreenAlertLayout( Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2) Spacer(Modifier.fillMaxHeight().weight(1f)) Row { - LockScreenCallButton(stringResource(MR.strings.reject), painterResource(MR.images.ic_call_end_filled), Color.Red, rejectCall) + LockScreenCallButton(stringResource(MR.strings.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall) Spacer(Modifier.size(48.dp)) - LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(MR.images.ic_close), MaterialTheme.colors.primary, ignoreCall) + LockScreenCallButton(stringResource(MR.strings.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall) Spacer(Modifier.size(48.dp)) - LockScreenCallButton(stringResource(MR.strings.accept), painterResource(MR.images.ic_check_filled), SimplexGreen, acceptCall) + LockScreenCallButton(stringResource(MR.strings.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall) } } else if (callOnLockScreen == CallOnLockScreen.SHOW) { SimpleXLogo() Text(stringResource(MR.strings.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp) Text(stringResource(MR.strings.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp) Spacer(Modifier.fillMaxHeight().weight(1f)) - SimpleButton(text = stringResource(MR.strings.open_verb), icon = painterResource(MR.images.ic_check_filled), click = openApp) + SimpleButton(text = stringResource(MR.strings.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp) } } } @@ -190,7 +188,7 @@ fun IncomingCallLockScreenAlertLayout( @Composable private fun SimpleXLogo() { Image( - painter = painterResource(if (isInDarkTheme()) MR.images.logo_light else MR.images.logo), + painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo), contentDescription = stringResource(MR.strings.image_descr_simplex_logo), modifier = Modifier .padding(vertical = DEFAULT_PADDING) @@ -219,10 +217,10 @@ private fun LockScreenCallButton(text: String, icon: Painter, color: Color, acti } } -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true -) +)*/ @Composable fun PreviewIncomingCallLockScreenAlert() { SimpleXTheme(true) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt deleted file mode 100644 index 38b2f0e219..0000000000 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt +++ /dev/null @@ -1,62 +0,0 @@ -package chat.simplex.app.views.helpers - -import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -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.views.newchat.ActionButton -import chat.simplex.res.MR - -sealed class AttachmentOption { - object CameraPhoto: AttachmentOption() - object GalleryImage: AttachmentOption() - object GalleryVideo: AttachmentOption() - object File: AttachmentOption() -} - -@Composable -fun ChooseAttachmentView( - attachmentOption: MutableState, - hide: () -> Unit -) { - Box( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .onFocusChanged { focusState -> - if (!focusState.hasFocus) hide() - } - ) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 30.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) { - attachmentOption.value = AttachmentOption.CameraPhoto - hide() - } - ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { - attachmentOption.value = AttachmentOption.GalleryImage - hide() - } - ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) { - attachmentOption.value = AttachmentOption.GalleryVideo - hide() - } - ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { - attachmentOption.value = AttachmentOption.File - hide() - } - } - } -} 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 deleted file mode 100644 index b6edae0e8f..0000000000 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LocalAuthentication.kt +++ /dev/null @@ -1,148 +0,0 @@ -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.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, - promptSubtitle: String, - selfDestruct: Boolean = false, - usingLAMode: LAMode = SimplexApp.context.chatModel.controller.appPrefs.laMode.get(), - completed: (LAResult) -> Unit -) { - val activity = SimplexApp.context.mainActivity.get() ?: return completed(LAResult.Error("")) - when (usingLAMode) { - LAMode.SYSTEM -> when { - SDK_INT in 28..29 -> - // KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types - authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL) - SDK_INT > 29 -> - authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL) - else -> completed(LAResult.Unavailable()) - } - 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) - }) - } - } - } - } -} - -private fun authenticateWithBiometricManager( - promptTitle: String, - promptSubtitle: String, - activity: FragmentActivity, - completed: (LAResult) -> Unit, - authenticators: Int -) { - val biometricManager = BiometricManager.from(activity) - when (biometricManager.canAuthenticate(authenticators)) { - BiometricManager.BIOMETRIC_SUCCESS -> { - val executor = ContextCompat.getMainExecutor(activity) - val biometricPrompt = BiometricPrompt( - activity, - executor, - object: BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError( - errorCode: Int, - errString: CharSequence - ) { - super.onAuthenticationError(errorCode, errString) - completed(LAResult.Error(errString)) - } - - override fun onAuthenticationSucceeded( - result: BiometricPrompt.AuthenticationResult - ) { - super.onAuthenticationSucceeded(result) - completed(LAResult.Success) - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - completed(LAResult.Failed()) - } - } - ) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(promptTitle) - .setSubtitle(promptSubtitle) - .setAllowedAuthenticators(authenticators) - .setConfirmationRequired(false) - .build() - biometricPrompt.authenticate(promptInfo) - } - else -> completed(LAResult.Unavailable()) - } -} - -fun laTurnedOnAlert() = AlertManager.shared.showAlertMsg( - generalGetString(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/Share.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Share.kt deleted file mode 100644 index 10ef455a48..0000000000 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ /dev/null @@ -1,128 +0,0 @@ -package chat.simplex.app.views.helpers - -import android.Manifest -import android.content.* -import android.net.Uri -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.res.MR -import java.io.BufferedOutputStream -import java.io.File - -fun shareText(text: String) { - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, text) - type = "text/plain" - } - val shareIntent = Intent.createChooser(sendIntent, null) - // This flag is needed when you start a new activity from non-Activity context - shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - SimplexApp.context.startActivity(shareIntent) -} - -fun shareFile(text: String, filePath: String) { - val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) - val ext = filePath.substringAfterLast(".") - val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - /*if (text.isNotEmpty()) { - putExtra(Intent.EXTRA_TEXT, text) - }*/ - putExtra(Intent.EXTRA_STREAM, uri) - type = mimeType - } - val shareIntent = Intent.createChooser(sendIntent, null) - // This flag is needed when you start a new activity from non-Activity context - shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - SimplexApp.context.startActivity(shareIntent) -} - -fun copyText(text: String) { - val clipboard = ContextCompat.getSystemService(SimplexApp.context, ClipboardManager::class.java) - clipboard?.setPrimaryClip(ClipData.newPlainText("text", text)) -} - -fun sendEmail(subject: String, body: CharSequence) { - val emailIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")) - emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject) - emailIntent.putExtra(Intent.EXTRA_TEXT, body) - // This flag is needed when you start a new activity from non-Activity context - emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - try { - SimplexApp.context.startActivity(emailIntent) - } catch (e: ActivityNotFoundException) { - Log.e(TAG, "No activity was found for handling email intent") - } -} - -@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 { - lowercaseName.endsWith(".png") -> "image/png" - lowercaseName.endsWith(".gif") -> "image/gif" - lowercaseName.endsWith(".webp") -> "image/webp" - lowercaseName.endsWith(".avif") -> "image/avif" - lowercaseName.endsWith(".svg") -> "image/svg+xml" - else -> "image/jpeg" - } -} - -/** Before calling, make sure the user allows to write to external storage [Manifest.permission.WRITE_EXTERNAL_STORAGE] */ -fun saveImage(ciFile: CIFile?) { - val cxt = SimplexApp.context - val filePath = getLoadedFilePath(ciFile) - val fileName = ciFile?.fileName - if (filePath != null && fileName != null) { - val values = ContentValues() - values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - values.put(MediaStore.Images.Media.MIME_TYPE, imageMimeType(fileName)) - values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) - values.put(MediaStore.MediaColumns.TITLE, fileName) - val uri = cxt.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) - uri?.let { - cxt.contentResolver.openOutputStream(uri)?.let { stream -> - val outputStream = BufferedOutputStream(stream) - File(filePath).inputStream().use { it.copyTo(outputStream) } - outputStream.close() - Toast.makeText(cxt, generalGetString(MR.strings.image_saved), Toast.LENGTH_SHORT).show() - } - } - } else { - Toast.makeText(cxt, generalGetString(MR.strings.file_not_found), Toast.LENGTH_SHORT).show() - } -} 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 3e9926b17b..e69de29bb2 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,687 +0,0 @@ -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 -import android.graphics.drawable.Drawable -import android.media.MediaMetadataRetriever -import android.net.Uri -import android.os.* -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.* -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.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) - .substringAfter("

").substringBeforeLast("

") -} - -fun Resources.getText(id: StringResource, vararg args: Any): CharSequence { - val escapedArgs = args.map { - if (it is Spanned) it.toHtmlWithoutParagraphs() else it - }.toTypedArray() - val resource = SpannedString(getText(id)) - val htmlResource = resource.toHtmlWithoutParagraphs() - val formattedHtml = String.format(htmlResource, *escapedArgs) - return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) -} - -fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { - return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density) -} - -@Composable -fun annotatedStringResource(id: StringResource): AnnotatedString { - val density = LocalDensity.current - return remember(id) { - val text = id.getString(SimplexApp.context) - escapedHtmlToAnnotatedString(text, density) - } -} - -private fun spannableStringToAnnotatedString( - text: CharSequence, - density: Density, -): AnnotatedString { - return if (text is Spanned) { - with(density) { - buildAnnotatedString { - append((text.toString())) - text.getSpans(0, text.length, Any::class.java).forEach { - val start = text.getSpanStart(it) - val end = text.getSpanEnd(it) - when (it) { - is StyleSpan -> when (it.style) { - Typeface.NORMAL -> addStyle( - SpanStyle( - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Normal, - ), - start, - end - ) - Typeface.BOLD -> addStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Normal - ), - start, - end - ) - Typeface.ITALIC -> addStyle( - SpanStyle( - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Italic - ), - start, - end - ) - Typeface.BOLD_ITALIC -> addStyle( - SpanStyle( - fontWeight = FontWeight.Bold, - fontStyle = FontStyle.Italic - ), - start, - end - ) - } - is TypefaceSpan -> addStyle( - SpanStyle( - fontFamily = when (it.family) { - FontFamily.SansSerif.name -> FontFamily.SansSerif - FontFamily.Serif.name -> FontFamily.Serif - FontFamily.Monospace.name -> FontFamily.Monospace - FontFamily.Cursive.name -> FontFamily.Cursive - else -> FontFamily.Default - } - ), - start, - end - ) - is AbsoluteSizeSpan -> addStyle( - SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), - start, - end - ) - is RelativeSizeSpan -> addStyle( - SpanStyle(fontSize = it.sizeChange.em), - start, - end - ) - is StrikethroughSpan -> addStyle( - SpanStyle(textDecoration = TextDecoration.LineThrough), - start, - end - ) - is UnderlineSpan -> addStyle( - SpanStyle(textDecoration = TextDecoration.Underline), - start, - end - ) - is SuperscriptSpan -> addStyle( - SpanStyle(baselineShift = BaselineShift.Superscript), - start, - end - ) - is SubscriptSpan -> addStyle( - SpanStyle(baselineShift = BaselineShift.Subscript), - start, - end - ) - is ForegroundColorSpan -> addStyle( - SpanStyle(color = Color(it.foregroundColor)), - start, - end - ) - else -> addStyle(SpanStyle(color = Color.White), start, end) - } - } - } - } - } else { - AnnotatedString(text.toString()) - } -} - -// 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) - return if (filePath != null) { - try { - val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) - val parcelFileDescriptor = SimplexApp.context.contentResolver.openFileDescriptor(uri, "r") - val fileDescriptor = parcelFileDescriptor?.fileDescriptor - val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000) - parcelFileDescriptor?.close() - image - } catch (e: Exception) { - null - } - } else { - null - } -} - -// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap -private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap { - // First decode with inJustDecodeBounds=true to check dimensions - return BitmapFactory.Options().run { - inJustDecodeBounds = true - BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) - // Calculate inSampleSize - inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) - // Decode bitmap with inSampleSize set - inJustDecodeBounds = false - - BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) - } -} - -private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { - // Raw height and width of image - val (height: Int, width: Int) = options.run { outHeight to outWidth } - var inSampleSize = 1 - - if (height > reqHeight || width > reqWidth) { - val halfHeight: Int = height / 2 - val halfWidth: Int = width / 2 - // Calculate the largest inSampleSize value that is a power of 2 and keeps both - // height and width larger than the requested height and width. - while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { - inSampleSize *= 2 - } - } - - return inSampleSize -} - -fun getFileName(uri: Uri): String? { - return SimplexApp.context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - cursor.moveToFirst() - cursor.getString(nameIndex) - } -} - -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)) - } -} - -fun getFileSize(uri: Uri): Long? { - return SimplexApp.context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) - cursor.moveToFirst() - cursor.getLong(sizeIndex) - } -} - -fun getBitmapFromUri(uri: Uri, withAlertOnException: Boolean = true): Bitmap? { - return if (Build.VERSION.SDK_INT >= 28) { - val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri) - try { - ImageDecoder.decodeBitmap(source) - } catch (e: android.graphics.ImageDecoder.DecodeException) { - Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}") - if (withAlertOnException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.image_decoding_exception_title), - text = generalGetString(MR.strings.image_decoding_exception_desc) - ) - } - null - } - } else { - BitmapFactory.decodeFile(getAppFilePath(uri)) - } -} - -fun getDrawableFromUri(uri: Uri, withAlertOnException: Boolean = true): Drawable? { - return if (Build.VERSION.SDK_INT >= 28) { - val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri) - try { - ImageDecoder.decodeDrawable(source) - } catch (e: android.graphics.ImageDecoder.DecodeException) { - if (withAlertOnException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.image_decoding_exception_title), - text = generalGetString(MR.strings.image_decoding_exception_desc) - ) - } - Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}") - null - } - } else { - Drawable.createFromPath(getAppFilePath(uri)) - } -} - -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" - val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE) - return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply { - outputStream().use { out -> - image.compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out) - out.flush() - } - deleteOnExit() - SimplexApp.context.chatModel.filesToDelete.add(this) - } - } catch (e: Exception) { - Log.e(TAG, "Util.kt saveTempImageUncompressed 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(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) - val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() - val image = when { - timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST) - random -> mmr.frameAtTime - else -> mmr.getFrameAtTime(0) - } - 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() ?: 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/usersettings/Appearance.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt deleted file mode 100644 index cdf1f9900a..0000000000 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Appearance.kt +++ /dev/null @@ -1,424 +0,0 @@ -package chat.simplex.app.views.usersettings - -import SectionBottomSpacer -import SectionDividerSpaced -import SectionItemView -import SectionItemViewSpaceBetween -import SectionSpacer -import SectionView -import android.app.Activity -import android.content.ComponentName -import android.content.Context -import android.content.pm.PackageManager -import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED -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.foundation.lazy.LazyRow -import androidx.compose.material.* -import androidx.compose.material.MaterialTheme.colors -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.* -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -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 androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toBitmap -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.godaddy.android.colorpicker.* -import chat.simplex.res.MR -import kotlinx.coroutines.delay -import kotlinx.serialization.encodeToString -import java.io.BufferedOutputStream -import java.util.* -import kotlin.collections.ArrayList - -enum class AppIcon(val resId: Int) { - DEFAULT(R.mipmap.icon), - DARK_BLUE(R.mipmap.icon_dark_blue), -} - -@Composable -fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { - val appIcon = remember { mutableStateOf(findEnabledIcon()) } - - fun setAppIcon(newIcon: AppIcon) { - if (appIcon.value == newIcon) return - val newComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}") - val oldComponent = ComponentName(BuildConfig.APPLICATION_ID, "chat.simplex.app.MainActivity_${appIcon.value.name.lowercase()}") - SimplexApp.context.packageManager.setComponentEnabledSetting( - newComponent, - COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP - ) - - SimplexApp.context.packageManager.setComponentEnabledSetting( - oldComponent, - PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP - ) - - appIcon.value = newIcon - } - - AppearanceLayout( - appIcon, - m.controller.appPrefs.appLanguage, - m.controller.appPrefs.systemDarkTheme, - changeIcon = ::setAppIcon, - showSettingsModal = showSettingsModal, - editColor = { name, initialColor -> - ModalManager.shared.showModalCloseable { close -> - ColorEditor(name, initialColor, close) - } - }, - ) -} - -@Composable fun AppearanceLayout( - icon: MutableState, - languagePref: SharedPreference, - systemDarkTheme: SharedPreference, - changeIcon: (AppIcon) -> Unit, - showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), - editColor: (ThemeColor, Color) -> Unit, -) { - Column( - Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), - ) { - AppBarTitle(stringResource(MR.strings.appearance_settings)) - SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) { - val context = LocalContext.current -// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { -// SectionItemWithValue( -// generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() }, -// remember { mutableStateOf("system") }, -// listOf(ValueTitleDesc("system", generalGetString(MR.strings.change_verb), "")), -// onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) } -// ) -// } else { - val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") } - LangSelector(state) { - state.value = it - withApi { - delay(200) - val activity = context as? Activity - if (activity != null) { - if (it == "system") { - saveAppLocale(languagePref, activity) - } else { - saveAppLocale(languagePref, activity, it) - } - } - } - } -// } - } - SectionDividerSpaced() - - SectionView(stringResource(MR.strings.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { - LazyRow { - items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index -> - val item = AppIcon.values()[index] - val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!! - Image( - bitmap = mipmap.toBitmap().asImageBitmap(), - contentDescription = "", - contentScale = ContentScale.Fit, - modifier = Modifier - .shadow(if (item == icon.value) 1.dp else 0.dp, ambientColor = colors.secondaryVariant) - .size(70.dp) - .clickable { changeIcon(item) } - .padding(10.dp) - ) - - if (index + 1 != AppIcon.values().size) { - Spacer(Modifier.padding(horizontal = 4.dp)) - } - } - } - } - - 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)) } - 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()}") - ).let { it == COMPONENT_ENABLED_STATE_DEFAULT || it == COMPONENT_ENABLED_STATE_ENABLED } -} - -@Preview(showBackground = true) -@Composable -fun PreviewAppearanceSettings() { - SimpleXTheme { - AppearanceLayout( - icon = remember { mutableStateOf(AppIcon.DARK_BLUE) }, - languagePref = SharedPreference({ null }, {}), - systemDarkTheme = SharedPreference({ null }, {}), - changeIcon = {}, - showSettingsModal = { {} }, - editColor = { _, _ -> }, - ) - } -} diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 94cecd1725..4e7ac583a5 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -55,7 +55,6 @@ allprojects { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") - maven("https://jitpack.io") } } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 092f87690b..5b45a2317f 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -31,7 +31,6 @@ kotlin { } val commonMain by getting { - kotlin.srcDir("./build/generated/moko/commonMain/src/") dependencies { api(compose.runtime) api(compose.foundation) @@ -57,49 +56,39 @@ kotlin { implementation(kotlin("test")) } } - // LALAL CHANGE TO IMPLEMENTATION val androidMain by getting { - kotlin.srcDir("./build/generated/moko/commonMain/src/") dependencies { - api("androidx.appcompat:appcompat:1.5.1") - api("androidx.core:core-ktx:1.9.0") - api("androidx.activity:activity-compose:1.5.0") + implementation("androidx.activity:activity-compose:1.5.0") val work_version = "2.7.1" - api("androidx.work:work-runtime-ktx:$work_version") - api("androidx.work:work-multiprocess:$work_version") - api("com.google.accompanist:accompanist-insets:0.23.0") - api("dev.icerock.moko:resources:0.22.3") + implementation("androidx.work:work-runtime-ktx:$work_version") + implementation("com.google.accompanist:accompanist-insets:0.23.0") + implementation("dev.icerock.moko:resources:0.22.3") // Video support - api("com.google.android.exoplayer:exoplayer:2.17.1") + implementation("com.google.android.exoplayer:exoplayer:2.17.1") // Biometric authentication - api("androidx.biometric:biometric:1.2.0-alpha04") + implementation("androidx.biometric:biometric:1.2.0-alpha04") //Barcode - api("org.boofcv:boofcv-android:0.40.1") + implementation("org.boofcv:boofcv-android:0.40.1") //Camera Permission - api("com.google.accompanist:accompanist-permissions:0.23.0") + implementation("com.google.accompanist:accompanist-permissions:0.23.0") - api("androidx.webkit:webkit:1.4.0") + implementation("androidx.webkit:webkit:1.4.0") // GIFs support - api("io.coil-kt:coil-compose:2.1.0") - api("io.coil-kt:coil-gif:2.1.0") + implementation("io.coil-kt:coil-compose:2.1.0") + implementation("io.coil-kt:coil-gif:2.1.0") - api("com.jakewharton:process-phoenix:2.1.2") + implementation("com.jakewharton:process-phoenix:2.1.2") val camerax_version = "1.1.0-beta01" - api("androidx.camera:camera-core:${camerax_version}") - api("androidx.camera:camera-camera2:${camerax_version}") - api("androidx.camera:camera-lifecycle:${camerax_version}") - api("androidx.camera:camera-view:${camerax_version}") - - // LALAL REMOVE - api("org.jsoup:jsoup:1.13.1") - api("com.godaddy.android.colorpicker:compose-color-picker-jvm:0.7.0") - api("androidx.compose.ui:ui-tooling-preview:${extra["compose.version"]}") + implementation("androidx.camera:camera-core:${camerax_version}") + implementation("androidx.camera:camera-camera2:${camerax_version}") + implementation("androidx.camera:camera-lifecycle:${camerax_version}") + implementation("androidx.camera:camera-view:${camerax_version}") } } val desktopMain by getting { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt new file mode 100644 index 0000000000..e237272eb0 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Extensions.kt @@ -0,0 +1,22 @@ +package chat.simplex.common.helpers + +import android.net.Uri +import android.os.Build +import chat.simplex.common.model.NotificationsMode +import java.net.URI + +val NotificationsMode.requiresIgnoringBatterySinceSdk: Int get() = when(this) { + NotificationsMode.OFF -> Int.MAX_VALUE + NotificationsMode.PERIODIC -> Build.VERSION_CODES.M + NotificationsMode.SERVICE -> Build.VERSION_CODES.S + /*INSTANT -> Int.MAX_VALUE - for Firebase notifications */ +} + +val NotificationsMode.requiresIgnoringBattery + get() = requiresIgnoringBatterySinceSdk <= Build.VERSION.SDK_INT + +lateinit var APPLICATION_ID: String + +fun Uri.toURI(): URI = URI(toString()) + +fun URI.toUri(): Uri = Uri.parse(toString()) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Locale.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Locale.kt new file mode 100644 index 0000000000..b9d7d27ba9 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Locale.kt @@ -0,0 +1,38 @@ +package chat.simplex.common.helpers + +import android.app.Activity +import android.content.res.Configuration +import chat.simplex.common.model.SharedPreference +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.defaultLocale +import java.util.* + +fun Activity.saveAppLocale(pref: SharedPreference, 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) { + applyLocale(defaultLocale) + } + recreate() + // } +} + +fun Activity.applyAppLocale(pref: SharedPreference) { + // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + val lang = pref.get() ?: return + applyLocale(Locale.forLanguageTag(lang)) + // } +} + +private fun Activity.applyLocale(locale: Locale) { + Locale.setDefault(locale) + val appConf = Configuration(androidAppContext.resources.configuration).apply { setLocale(locale) } + val activityConf = Configuration(resources.configuration).apply { setLocale(locale) } + @Suppress("DEPRECATION") + androidAppContext.resources.updateConfiguration(appConf, resources.displayMetrics) + @Suppress("DEPRECATION") + resources.updateConfiguration(activityConf, resources.displayMetrics) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt similarity index 60% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt index 314032984a..25369ffdfd 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/SoundPlayer.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt @@ -1,22 +1,22 @@ -package chat.simplex.app.views.call +package chat.simplex.common.helpers -import android.content.Context import android.media.* import android.net.Uri import android.os.VibrationEffect import android.os.Vibrator import androidx.core.content.ContextCompat -import chat.simplex.app.R -import chat.simplex.app.SimplexApp -import chat.simplex.app.views.helpers.withScope +import chat.simplex.common.R +import chat.simplex.common.platform.SoundPlayerInterface +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.views.helpers.withScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -class SoundPlayer { +object SoundPlayer: SoundPlayerInterface { private var player: MediaPlayer? = null var playing = false - fun start(scope: CoroutineScope, sound: Boolean) { + override fun start(scope: CoroutineScope, sound: Boolean) { player?.reset() player = MediaPlayer().apply { setAudioAttributes( @@ -25,10 +25,10 @@ class SoundPlayer { .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .build() ) - setDataSource(SimplexApp.context, Uri.parse("android.resource://" + SimplexApp.context.packageName + "/" + R.raw.ring_once)) + setDataSource(androidAppContext, Uri.parse("android.resource://" + androidAppContext.packageName + "/" + R.raw.ring_once)) prepare() } - val vibrator = ContextCompat.getSystemService(SimplexApp.context, Vibrator::class.java) + val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java) val effect = VibrationEffect.createOneShot(250, VibrationEffect.DEFAULT_AMPLITUDE) playing = true withScope(scope) { @@ -40,12 +40,8 @@ class SoundPlayer { } } - fun stop() { + override fun stop() { playing = false player?.stop() } - - companion object { - val shared = SoundPlayer() - } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt new file mode 100644 index 0000000000..39b62a1c8b --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -0,0 +1,70 @@ +package chat.simplex.common.platform + +import android.annotation.SuppressLint +import android.content.Context +import android.net.LocalServerSocket +import android.util.Log +import androidx.fragment.app.FragmentActivity +import chat.simplex.common.* +import chat.simplex.common.platform.* +import java.io.* +import java.lang.ref.WeakReference +import java.util.* +import java.util.concurrent.Semaphore +import kotlin.concurrent.thread +import kotlin.random.Random + +actual val appPlatform = AppPlatform.ANDROID + +var isAppOnForeground: Boolean = false + +@Suppress("ConstantLocale") +val defaultLocale: Locale = Locale.getDefault() + +@SuppressLint("StaticFieldLeak") +lateinit var androidAppContext: Context +lateinit var mainActivity: WeakReference + +actual fun initHaskell() { + val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000) + 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/common/src/androidMain/kotlin/chat/simplex/common/platform/Back.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Back.android.kt new file mode 100644 index 0000000000..dfaeac695e --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Back.android.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* + +@SuppressWarnings("MissingJvmstatic") +@Composable +actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { + androidx.activity.compose.BackHandler(enabled, onBack) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt similarity index 83% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt index 1099b2fb2d..dc6c53ecbc 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Cryptor.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -1,24 +1,23 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.platform import android.annotation.SuppressLint import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties -import android.util.Log -import chat.simplex.app.R -import chat.simplex.app.TAG -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import java.security.KeyStore import javax.crypto.* import javax.crypto.spec.GCMParameterSpec +actual val cryptor: CryptorInterface = Cryptor() + @SuppressLint("ObsoleteSdkInt") -internal class Cryptor { +internal class Cryptor: CryptorInterface { private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } private var warningShown = false - fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? { val secretKey = getSecretKey(alias) if (secretKey == null) { if (!warningShown) { @@ -37,13 +36,13 @@ internal class Cryptor { return runCatching { String(cipher.doFinal(data))}.onFailure { Log.e(TAG, "doFinal: ${it.stackTraceToString()}") }.getOrNull() } - fun encryptText(text: String, alias: String): Pair { + override fun encryptText(text: String, alias: String): Pair { val cipher: Cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, createSecretKey(alias)) return Pair(cipher.doFinal(text.toByteArray(charset("UTF-8"))), cipher.iv) } - fun deleteKey(alias: String) { + override fun deleteKey(alias: String) { if (!keyStore.containsAlias(alias)) return keyStore.deleteEntry(alias) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt new file mode 100644 index 0000000000..213d05dcb0 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt @@ -0,0 +1,69 @@ +package chat.simplex.common.platform + +import android.app.Application +import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import chat.simplex.common.helpers.toURI +import chat.simplex.common.helpers.toUri +import java.io.* +import java.net.URI + +actual val dataDir: File = androidAppContext.dataDir +actual val tmpDir: File = androidAppContext.getDir("temp", Application.MODE_PRIVATE) +actual val filesDir: File = File(dataDir.absolutePath + File.separator + "files") +actual val appFilesDir: File = File(filesDir.absolutePath + File.separator + "app_files") +actual val coreTmpDir: File = File(filesDir.absolutePath + File.separator + "temp_files") +actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "files" + +actual val chatDatabaseFileName: String = "files_chat.db" +actual val agentDatabaseFileName: String = "files_agent.db" + +actual val databaseExportDir: File = androidAppContext.cacheDir + +@Composable +actual fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher { + val launcher = rememberLauncherForActivityResult( + contract = if (getContent) ActivityResultContracts.GetContent() else ActivityResultContracts.CreateDocument(), + onResult = { onResult(it?.toURI()) } + ) + return FileChooserLauncher(launcher) +} + +@Composable +actual fun rememberFileChooserMultipleLauncher(onResult: (List) -> Unit): FileChooserMultipleLauncher { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents(), + onResult = { onResult(it.map { it.toURI() }) } + ) + return FileChooserMultipleLauncher(launcher) +} + +actual class FileChooserLauncher actual constructor() { + private lateinit var launcher: ManagedActivityResultLauncher + + constructor(launcher: ManagedActivityResultLauncher): this() { + this.launcher = launcher + } + + actual suspend fun launch(input: String) { + launcher.launch(input) + } +} + +actual class FileChooserMultipleLauncher actual constructor() { + private lateinit var launcher: ManagedActivityResultLauncher> + + constructor(launcher: ManagedActivityResultLauncher>): this() { + this.launcher = launcher + } + + actual suspend fun launch(input: String) { + launcher.launch(input) + } +} + +actual fun URI.inputStream(): InputStream? = androidAppContext.contentResolver.openInputStream(toUri()) +actual fun URI.outputStream(): OutputStream = androidAppContext.contentResolver.openOutputStream(toUri())!! diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt new file mode 100644 index 0000000000..4140cd19a9 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -0,0 +1,116 @@ +package chat.simplex.common.platform + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.AnimatedImageDrawable +import android.os.Build +import android.util.Base64 +import android.webkit.MimeTypeMap +import androidx.compose.ui.graphics.* +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.scale +import boofcv.android.ConvertBitmap +import boofcv.struct.image.GrayU8 +import chat.simplex.common.R +import chat.simplex.common.views.helpers.errorBitmap +import chat.simplex.common.views.helpers.getFileName +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.URI +import kotlin.math.min +import kotlin.math.sqrt + +actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { + val imageString = base64ImageString + .removePrefix("data:image/png;base64,") + .removePrefix("data:image/jpg;base64,") + return try { + val imageBytes = Base64.decode(imageString, Base64.NO_WRAP) + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap() + } catch (e: Exception) { + Log.e(TAG, "base64ToBitmap error: $e") + errorBitmap.asImageBitmap() + } +} + +actual fun resizeImageToStrSize(image: ImageBitmap, 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.asAndroidBitmap(), width, height, true).asImageBitmap() + str = compressImageStr(img) + } + return str +} + +// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery +actual fun cropToSquare(image: ImageBitmap): ImageBitmap { + 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.asAndroidBitmap(), xOffset, yOffset, side, side).asImageBitmap() +} + +actual fun compressImageStr(bitmap: ImageBitmap): 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) +} + +actual fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream { + val stream = ByteArrayOutputStream() + bitmap.asAndroidBitmap().compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream) + return stream +} + +actual fun resizeImageToDataSize(image: ImageBitmap, 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.asAndroidBitmap(), width, height, true).asImageBitmap() + stream = compressImageData(img, usePng) + } + return stream +} + +actual fun GrayU8.toImageBitmap(): ImageBitmap = ConvertBitmap.grayToBitmap(this, Bitmap.Config.RGB_565).asImageBitmap() + +actual fun ImageBitmap.addLogo(): ImageBitmap = asAndroidBitmap().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 = androidAppContext.resources.getDrawable(R.drawable.icon_foreground_android_common, 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) +}.asImageBitmap() + +actual fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap = asAndroidBitmap().scale(width, height).asImageBitmap() + +actual fun isImage(uri: URI): Boolean = + MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(uri)?.split(".")?.last())?.contains("image/") == true + +actual 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 +} + +actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = + BitmapFactory.decodeStream(inputStream).asImageBitmap() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Log.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Log.android.kt new file mode 100644 index 0000000000..aca8efcb6f --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Log.android.kt @@ -0,0 +1,10 @@ +package chat.simplex.common.platform + +import android.util.Log + +actual object Log { + actual fun d(tag: String, text: String) = Log.d(tag, text).run{} + actual fun e(tag: String, text: String) = Log.e(tag, text).run{} + actual fun i(tag: String, text: String) = Log.i(tag, text).run{} + actual fun w(tag: String, text: String) = Log.w(tag, text).run{} +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt new file mode 100644 index 0000000000..f4f748ef0f --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -0,0 +1,16 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.google.accompanist.insets.navigationBarsWithImePadding + +actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding() + +@Composable +actual fun ProvideWindowInsets( + consumeWindowInsets: Boolean, + windowInsetsAnimationsEnabled: Boolean, + content: @Composable () -> Unit +) { + com.google.accompanist.insets.ProvideWindowInsets(content = content) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Notifications.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Notifications.android.kt new file mode 100644 index 0000000000..f0268219d2 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Notifications.android.kt @@ -0,0 +1,3 @@ +package chat.simplex.common.platform + +actual fun allowedToShowNotification(): Boolean = !isAppOnForeground diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt new file mode 100644 index 0000000000..e6c6ebad09 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -0,0 +1,160 @@ +package chat.simplex.common.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.common.* +import chat.simplex.common.R +import chat.simplex.common.helpers.toURI +import chat.simplex.common.model.ChatModel +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.SharedContent +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.delay +import java.lang.reflect.Field +import java.net.URI + +@Composable +actual fun PlatformTextField( + 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.toURI())) + 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 = androidAppContext.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 = androidAppContext.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/views/helpers/RecAndPlay.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt similarity index 86% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index 01387dd963..c24ade47d9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.platform import android.app.Application import android.content.Context @@ -7,34 +7,22 @@ import android.media.AudioManager.AudioPlaybackCallback import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED import android.os.Build -import android.util.Log import androidx.compose.runtime.* -import chat.simplex.app.* -import chat.simplex.app.R -import chat.simplex.app.model.ChatItem -import chat.simplex.app.views.helpers.AudioPlayer.duration import chat.simplex.res.MR +import chat.simplex.common.model.ChatItem +import chat.simplex.common.platform.AudioPlayer.duration +import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* import java.io.* -interface Recorder { - fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String - fun stop(): Int -} - -class RecorderNative(): Recorder { - companion object { - // Allows to stop the recorder from outside without having the recorder in a variable - var stopRecording: (() -> Unit)? = null - const val extension = "m4a" - } +actual class RecorderNative: RecorderInterface { private var recorder: MediaRecorder? = null private var progressJob: Job? = null private var filePath: String? = null private var recStartedAt: Long? = null private fun initRecorder() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(SimplexApp.context) + MediaRecorder(androidAppContext) } else { MediaRecorder() } @@ -51,8 +39,7 @@ class RecorderNative(): Recorder { rec.setAudioSamplingRate(16000) rec.setAudioEncodingBitRate(32000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) - val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE) - val fileToSave = File.createTempFile(generateNewFileName("voice", "${extension}_"), ".tmp", tmpDir) + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_"), ".tmp", tmpDir) fileToSave.deleteOnExit() val path = fileToSave.absolutePath filePath = path @@ -75,13 +62,13 @@ class RecorderNative(): Recorder { stop() } } - stopRecording = { stop() } + RecorderInterface.stopRecording = { stop() } return path } override fun stop(): Int { val path = filePath ?: return 0 - stopRecording = null + RecorderInterface.stopRecording = null runCatching { recorder?.stop() } @@ -110,7 +97,7 @@ class RecorderNative(): Recorder { private fun realDuration(path: String): Int? = duration(path) ?: progress() } -object AudioPlayer { +actual object AudioPlayer: AudioPlayerInterface { private val player = MediaPlayer().apply { setAudioAttributes( AudioAttributes.Builder() @@ -118,13 +105,13 @@ object AudioPlayer { .setUsage(AudioAttributes.USAGE_MEDIA) .build() ) - (SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager) + (androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager) .registerAudioPlaybackCallback(object: AudioPlaybackCallback() { override fun onPlaybackConfigChanged(configs: MutableList?) { if (configs?.any { it.audioAttributes.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION } == true) { // In a process of making a call - RecorderNative.stopRecording?.invoke() - stop() + RecorderInterface.stopRecording?.invoke() + AudioPlayer.stop() } super.onPlaybackConfigChanged(configs) } @@ -154,7 +141,7 @@ object AudioPlayer { } VideoPlayer.stopAll() - RecorderNative.stopRecording?.invoke() + RecorderInterface.stopRecording?.invoke() val current = currentlyPlaying.value if (current == null || current.first != filePath) { stopListener() @@ -208,16 +195,16 @@ object AudioPlayer { return player.currentPosition } - fun stop() { + override fun stop() { if (currentlyPlaying.value == null) return player.stop() stopListener() } - fun stop(item: ChatItem) = stop(item.file?.fileName) + override fun stop(item: ChatItem) = stop(item.file?.fileName) // FileName or filePath are ok - fun stop(fileName: String?) { + override fun stop(fileName: String?) { if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) { stop() } @@ -241,7 +228,7 @@ object AudioPlayer { progressJob = null } - fun play( + override fun play( filePath: String?, audioPlaying: MutableState, progress: MutableState, @@ -269,19 +256,19 @@ object AudioPlayer { realDuration?.let { duration.value = it } } - fun pause(audioPlaying: MutableState, pro: MutableState) { + override fun pause(audioPlaying: MutableState, pro: MutableState) { pro.value = pause() audioPlaying.value = false } - fun seekTo(ms: Int, pro: MutableState, filePath: String?) { + override fun seekTo(ms: Int, pro: MutableState, filePath: String?) { pro.value = ms - if (this.currentlyPlaying.value?.first == filePath) { + if (currentlyPlaying.value?.first == filePath) { player.seekTo(ms) } } - fun duration(filePath: String): Int? { + override fun duration(filePath: String): Int? { var res: Int? = null kotlin.runCatching { helperPlayer.setDataSource(filePath) @@ -294,3 +281,5 @@ object AudioPlayer { return res } } + +actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Resources.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Resources.android.kt new file mode 100644 index 0000000000..2604e0d268 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Resources.android.kt @@ -0,0 +1,51 @@ +package chat.simplex.common.platform + +import android.annotation.SuppressLint +import android.app.UiModeManager +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Configuration +import android.text.BidiFormatter +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.AppPreferences +import com.russhwolf.settings.Settings +import com.russhwolf.settings.SharedPreferencesSettings +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.desc.desc + +@SuppressLint("DiscouragedApi") +@Composable +actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font { + val context = LocalContext.current + val id = context.resources.getIdentifier(res, "font", context.packageName) + return Font(id, weight, style) +} + +actual fun StringResource.localized(): String = desc().toString(context = androidAppContext) + +actual fun isInNightMode() = + (androidAppContext.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES + +private val sharedPreferences: SharedPreferences by lazy { androidAppContext.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE) } +private val sharedPreferencesThemes: SharedPreferences by lazy { androidAppContext.getSharedPreferences(AppPreferences.SHARED_PREFS_THEMES_ID, Context.MODE_PRIVATE) } + +actual val settings: Settings by lazy { SharedPreferencesSettings(sharedPreferences) } +actual val settingsThemes: Settings by lazy { SharedPreferencesSettings(sharedPreferencesThemes) } + +actual fun screenOrientation(): ScreenOrientation = when (mainActivity.get()?.resources?.configuration?.orientation) { + Configuration.ORIENTATION_PORTRAIT -> ScreenOrientation.PORTRAIT + Configuration.ORIENTATION_LANDSCAPE -> ScreenOrientation.LANDSCAPE + else -> ScreenOrientation.UNDEFINED +} + +@Composable +actual fun screenWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp + +actual fun isRtl(text: CharSequence): Boolean = BidiFormatter.getInstance().isRtl(text) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt new file mode 100644 index 0000000000..811974b2d5 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -0,0 +1,95 @@ +package chat.simplex.common.platform + +import android.Manifest +import android.content.* +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.net.Uri +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.UriHandler +import chat.simplex.common.helpers.toUri +import chat.simplex.common.model.CIFile +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.getAppFileUri +import java.io.BufferedOutputStream +import java.io.File +import chat.simplex.res.MR + +actual fun ClipboardManager.shareText(text: String) { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + flags = FLAG_ACTIVITY_NEW_TASK + } + val shareIntent = Intent.createChooser(sendIntent, null) + shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK) + androidAppContext.startActivity(shareIntent) +} + +actual fun shareFile(text: String, filePath: String) { + val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) + val ext = filePath.substringAfterLast(".") + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + /*if (text.isNotEmpty()) { + putExtra(Intent.EXTRA_TEXT, text) + }*/ + putExtra(Intent.EXTRA_STREAM, uri.toUri()) + type = mimeType + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + val shareIntent = Intent.createChooser(sendIntent, null) + shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK) + androidAppContext.startActivity(shareIntent) +} + +actual fun UriHandler.sendEmail(subject: String, body: CharSequence) { + val emailIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")) + emailIntent.putExtra(Intent.EXTRA_SUBJECT, subject) + emailIntent.putExtra(Intent.EXTRA_TEXT, body) + emailIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + androidAppContext.startActivity(emailIntent) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "No activity was found for handling email intent") + } +} + +fun imageMimeType(fileName: String): String { + val lowercaseName = fileName.lowercase() + return when { + lowercaseName.endsWith(".png") -> "image/png" + lowercaseName.endsWith(".gif") -> "image/gif" + lowercaseName.endsWith(".webp") -> "image/webp" + lowercaseName.endsWith(".avif") -> "image/avif" + lowercaseName.endsWith(".svg") -> "image/svg+xml" + else -> "image/jpeg" + } +} + +/** Before calling, make sure the user allows to write to external storage [Manifest.permission.WRITE_EXTERNAL_STORAGE] */ +fun saveImage(ciFile: CIFile?) { + val filePath = getLoadedFilePath(ciFile) + val fileName = ciFile?.fileName + if (filePath != null && fileName != null) { + val values = ContentValues() + values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) + values.put(MediaStore.Images.Media.MIME_TYPE, imageMimeType(fileName)) + values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + values.put(MediaStore.MediaColumns.TITLE, fileName) + val uri = androidAppContext.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + uri?.let { + androidAppContext.contentResolver.openOutputStream(uri)?.let { stream -> + val outputStream = BufferedOutputStream(stream) + File(filePath).inputStream().use { it.copyTo(outputStream) } + outputStream.close() + showToast(generalGetString(MR.strings.image_saved)) + } + } + } else { + showToast(generalGetString(MR.strings.file_not_found)) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt new file mode 100644 index 0000000000..c458561f96 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -0,0 +1,71 @@ +package chat.simplex.common.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 android.widget.Toast +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalView +import chat.simplex.common.views.helpers.KeyboardState +import androidx.compose.ui.platform.LocalContext as LocalContext1 + +actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() + +@Composable +actual fun LockToCurrentOrientationUntilDispose() { + val context = LocalContext1.current + DisposableEffect(Unit) { + val activity = (context as Activity?) ?: return@DisposableEffect onDispose {} + 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 +actual fun LocalMultiplatformView(): Any? = LocalView.current + +@Composable +actual 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 +} + +actual fun hideKeyboard(view: Any?) { + // LALAL + // LocalSoftwareKeyboardController.current?.hide() + if (view is View) { + (androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0) + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/VideoPlayer.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt similarity index 73% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/VideoPlayer.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt index fcaa6158df..984f83d455 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/VideoPlayer.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt @@ -1,45 +1,43 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.platform -import android.content.Context -import android.graphics.Bitmap import android.media.session.PlaybackState import android.net.Uri -import android.util.Log import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf -import chat.simplex.app.* -import chat.simplex.app.R +import androidx.compose.ui.graphics.ImageBitmap +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR import com.google.android.exoplayer2.* import com.google.android.exoplayer2.C.* import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.upstream.DefaultDataSource import com.google.android.exoplayer2.upstream.DefaultHttpDataSource -import chat.simplex.res.MR import kotlinx.coroutines.* import java.io.File +import java.net.URI -class VideoPlayer private constructor( - private val uri: Uri, +actual class VideoPlayer private constructor( + private val uri: URI, private val gallery: Boolean, - private val defaultPreview: Bitmap, + private val defaultPreview: ImageBitmap, defaultDuration: Long, - soundEnabled: Boolean, -) { - companion object { - private val players: MutableMap, VideoPlayer> = mutableMapOf() - private val previewsAndDurations: MutableMap = mutableMapOf() + soundEnabled: Boolean +): VideoPlayerInterface { + actual companion object { + private val players: MutableMap, VideoPlayer> = mutableMapOf() + private val previewsAndDurations: MutableMap = mutableMapOf() - fun getOrCreate( - uri: Uri, + actual fun getOrCreate( + uri: URI, gallery: Boolean, - defaultPreview: Bitmap, + defaultPreview: ImageBitmap, defaultDuration: Long, - soundEnabled: Boolean, + soundEnabled: Boolean ): VideoPlayer = players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) } - fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean = + actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean = player(fileName, gallery)?.enableSound(enable) == true private fun player(fileName: String?, gallery: Boolean): VideoPlayer? { @@ -47,36 +45,34 @@ class VideoPlayer private constructor( return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery } } - fun release(uri: Uri, gallery: Boolean, remove: Boolean) = - player(uri.path, gallery)?.release(remove) + actual fun release(uri: URI, gallery: Boolean, remove: Boolean) = + player(uri.path, gallery)?.release(remove).run { } - fun stopAll() { + actual fun stopAll() { players.values.forEach { it.stop() } } - fun releaseAll() { + actual fun releaseAll() { players.values.forEach { it.release(false) } players.clear() previewsAndDurations.clear() } } - data class PreviewAndDuration(val preview: Bitmap?, val duration: Long?, val timestamp: Long) - private val currentVolume: Float - val soundEnabled: MutableState = mutableStateOf(soundEnabled) - val brokenVideo: MutableState = mutableStateOf(false) - val videoPlaying: MutableState = mutableStateOf(false) - val progress: MutableState = mutableStateOf(0L) - val duration: MutableState = mutableStateOf(defaultDuration) - val preview: MutableState = mutableStateOf(defaultPreview) + override val soundEnabled: MutableState = mutableStateOf(soundEnabled) + override val brokenVideo: MutableState = mutableStateOf(false) + override val videoPlaying: MutableState = mutableStateOf(false) + override val progress: MutableState = mutableStateOf(0L) + override val duration: MutableState = mutableStateOf(defaultDuration) + override val preview: MutableState = mutableStateOf(defaultPreview) init { setPreviewAndDuration() } - val player = ExoPlayer.Builder(SimplexApp.context, - DefaultRenderersFactory(SimplexApp.context)) + val player = ExoPlayer.Builder(androidAppContext, + DefaultRenderersFactory(androidAppContext)) /*.setLoadControl(DefaultLoadControl.Builder() .setPrioritizeTimeOverSizeThresholds(false) // Could probably save some megabytes in memory in case it will be needed .createDefaultLoadControl())*/ @@ -84,20 +80,20 @@ class VideoPlayer private constructor( .setSeekForwardIncrementMs(10_000) .build() .apply { - // Repeat the same track endlessly - repeatMode = Player.REPEAT_MODE_ONE - currentVolume = volume - if (!soundEnabled) { - volume = 0f + // Repeat the same track endlessly + repeatMode = Player.REPEAT_MODE_ONE + currentVolume = volume + if (!soundEnabled) { + volume = 0f + } + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(CONTENT_TYPE_MUSIC) + .setUsage(USAGE_MEDIA) + .build(), + true // disallow to play multiple instances simultaneously + ) } - setAudioAttributes( - AudioAttributes.Builder() - .setContentType(CONTENT_TYPE_MUSIC) - .setUsage(USAGE_MEDIA) - .build(), - true // disallow to play multiple instances simultaneously - ) - } private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null) private var progressJob: Job? = null @@ -115,14 +111,14 @@ class VideoPlayer private constructor( } if (soundEnabled.value) { - RecorderNative.stopRecording?.invoke() + RecorderInterface.stopRecording?.invoke() } AudioPlayer.stop() stopAll() if (listener.value == null) { runCatching { - val dataSourceFactory = DefaultDataSource.Factory(SimplexApp.context, DefaultHttpDataSource.Factory()) - val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri)) + val dataSourceFactory = DefaultDataSource.Factory(androidAppContext, DefaultHttpDataSource.Factory()) + val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(Uri.parse(uri.toString()))) player.setMediaSource(source, seek ?: 0L) }.onFailure { Log.e(TAG, it.stackTraceToString()) @@ -170,14 +166,14 @@ class VideoPlayer private constructor( override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) // Produce non-ideal transition from stopped to playing state while showing preview image in ChatView -// videoPlaying.value = isPlaying + // videoPlaying.value = isPlaying } }) return true } - fun stop() { + override fun stop() { player.stop() stopListener() } @@ -199,7 +195,7 @@ class VideoPlayer private constructor( progressJob = null } - fun play(resetOnEnd: Boolean) { + override fun play(resetOnEnd: Boolean) { if (progress.value == duration.value) { progress.value = 0 } @@ -218,14 +214,14 @@ class VideoPlayer private constructor( } } - fun enableSound(enable: Boolean): Boolean { + override fun enableSound(enable: Boolean): Boolean { if (soundEnabled.value == enable) return false soundEnabled.value = enable player.volume = if (enable) currentVolume else 0f return true } - fun release(remove: Boolean) { + override fun release(remove: Boolean) { player.release() if (remove) { players.remove(uri to gallery) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt new file mode 100644 index 0000000000..056ae7495e --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt @@ -0,0 +1,13 @@ +package chat.simplex.common.ui.theme + +import androidx.compose.ui.text.font.* +import chat.simplex.res.MR + +actual val Inter: FontFamily = FontFamily( + Font(MR.fonts.Inter.regular.fontResourceId), + Font(MR.fonts.Inter.italic.fontResourceId, style = FontStyle.Italic), + Font(MR.fonts.Inter.bold.fontResourceId, FontWeight.Bold), + Font(MR.fonts.Inter.semibold.fontResourceId, FontWeight.SemiBold), + Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium), + Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light) +) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallView.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt similarity index 94% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallView.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index f937342f55..a549d985ae 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallView.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.call +package chat.simplex.common.views.call import android.Manifest import android.annotation.SuppressLint @@ -9,10 +9,9 @@ import android.media.* import android.os.Build import android.os.PowerManager import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK -import android.util.Log import android.view.ViewGroup import android.webkit.* -import androidx.activity.compose.BackHandler +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -26,22 +25,21 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle 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.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.common.views.helpers.withApi +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.Contact +import chat.simplex.common.platform.* import chat.simplex.res.MR +import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString @@ -49,20 +47,21 @@ import kotlinx.serialization.encodeToString @SuppressLint("SourceLockedOrientationActivity") @Composable -fun ActiveCallView(chatModel: ChatModel) { +actual fun ActiveCallView() { + val chatModel = ChatModel BackHandler(onBack = { val call = chatModel.activeCall.value if (call != null) withApi { chatModel.callManager.endCall(call) } }) val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } - val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name } + val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } LaunchedEffect(Unit) { // Start service when call happening since it's not already started. // It's needed to prevent Android from shutting down a microphone after a minute or so when screen is off - if (!ntfModeService) SimplexService.start() + if (!ntfModeService) platform.androidServiceStart() } DisposableEffect(Unit) { - val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager var btDeviceCount = 0 val audioCallback = object: AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array) { @@ -89,16 +88,16 @@ fun ActiveCallView(chatModel: ChatModel) { } } am.registerAudioDeviceCallback(audioCallback, null) - val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager) + val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager) val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { - pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock") + pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, androidAppContext.packageName + ":proximityLock") } else { null } proximityLock?.acquire() onDispose { // Stop it when call ended - if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context) + if (!ntfModeService) platform.androidServiceSafeStop() dropAudioManagerOverrides() am.unregisterAudioDeviceCallback(audioCallback) proximityLock?.release() @@ -217,7 +216,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot } private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState) { - val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker") am.mode = AudioManager.MODE_IN_COMMUNICATION if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -243,7 +242,7 @@ private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState= Build.VERSION_CODES.S) { @@ -353,7 +352,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) { InfoText(call.callState.text) val connInfo = call.connectionInfo -// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" + // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" InfoText(call.encryptionStatus + connInfoText) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt new file mode 100644 index 0000000000..719dfeea55 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt @@ -0,0 +1,81 @@ +package chat.simplex.common.views.chat + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.pm.PackageManager +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.ImageBitmap +import androidx.core.content.ContextCompat +import chat.simplex.common.helpers.toURI +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import java.net.URI + +@Composable +actual fun AttachmentSelection( + composeState: MutableState, + attachmentOption: MutableState, + processPickedFile: (URI?, String?) -> Unit, + processPickedMedia: (List, String?) -> Unit +) { + val cameraLauncher = rememberCameraLauncher { uri: Uri? -> + if (uri != null) { + val bitmap: ImageBitmap? = getBitmapFromUri(uri.toURI()) + if (bitmap != null) { + val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) + composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri.toURI())))) + } + } + } + val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> + if (isGranted) { + cameraLauncher.launchWithFallback() + } else { + showToast(generalGetString(MR.strings.toast_permission_denied)) + } + } + val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it.map { it.toURI() }, null) } + val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it.map { it.toURI() }, null) } + val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it.map { it.toURI() }, null) } + val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it.map { it.toURI() }, null) } + val filesLauncher = rememberGetContentLauncher { processPickedFile(it?.toURI(), null) } + LaunchedEffect(attachmentOption.value) { + when (attachmentOption.value) { + AttachmentOption.CameraPhoto -> { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission(androidAppContext, Manifest.permission.CAMERA) -> { + cameraLauncher.launchWithFallback() + } + else -> { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + attachmentOption.value = null + } + AttachmentOption.GalleryImage -> { + try { + galleryImageLauncher.launch(0) + } catch (e: ActivityNotFoundException) { + galleryImageLauncherFallback.launch("image/*") + } + attachmentOption.value = null + } + AttachmentOption.GalleryVideo -> { + try { + galleryVideoLauncher.launch(0) + } catch (e: ActivityNotFoundException) { + galleryVideoLauncherFallback.launch("video/*") + } + attachmentOption.value = null + } + AttachmentOption.File -> { + filesLauncher.launch("*/*") + attachmentOption.value = null + } + else -> {} + } + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt new file mode 100644 index 0000000000..79361dc073 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.android.kt @@ -0,0 +1,16 @@ +package chat.simplex.common.views.chat + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import chat.simplex.common.views.chat.ScanCodeLayout +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + ScanCodeLayout(verifyCode, close) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/SendMsgView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/SendMsgView.android.kt new file mode 100644 index 0000000000..8fb2263c7b --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/SendMsgView.android.kt @@ -0,0 +1,17 @@ +package chat.simplex.common.views.chat + +import android.Manifest +import androidx.compose.runtime.Composable +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +@Composable +actual fun allowedToRecordVoiceByPlatform(): Boolean { + val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO)) + return permissionsState.allPermissionsGranted +} + +@Composable +actual fun VoiceButtonWithoutPermissionByPlatform() { + val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO)) + VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt new file mode 100644 index 0000000000..25cbffb651 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt @@ -0,0 +1,53 @@ +package chat.simplex.common.views.chat.item + +import android.os.Build.VERSION.SDK_INT +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import chat.simplex.common.helpers.toUri +import chat.simplex.common.model.CIFile +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.ModalManager +import coil.ImageLoader +import coil.compose.rememberAsyncImagePainter +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.request.ImageRequest +import java.net.URI + +@Composable +actual fun SimpleAndAnimatedImageView( + uri: URI, + imageBitmap: ImageBitmap, + file: CIFile?, + imageProvider: () -> ImageGalleryProvider, + ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit +) { + val context = LocalContext.current + val imagePainter = rememberAsyncImagePainter( + ImageRequest.Builder(context).data(data = uri.toUri()).size(coil.size.Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil + imageLoader = imageLoader + ) + val view = LocalMultiplatformView() + ImageView(imagePainter) { + hideKeyboard(view) + if (getLoadedFilePath(file) != null) { + ModalManager.shared.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + } + } +} + +private val imageLoader = ImageLoader.Builder(androidAppContext) + .components { + if (SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } + .build() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.android.kt new file mode 100644 index 0000000000..f2f3e27766 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.android.kt @@ -0,0 +1,46 @@ +package chat.simplex.common.views.chat.item + +import android.graphics.Rect +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import chat.simplex.common.platform.VideoPlayer +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH +import com.google.android.exoplayer2.ui.StyledPlayerView + +@Composable +actual 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 + } + }, + Modifier + .width(width) + .combinedClickable( + onLongClick = onLongClick, + onClick = { if (player.player.playWhenReady) stop() else onClick() } + ) + ) +} + +@Composable +actual fun LocalWindowWidth(): Dp { + val view = LocalView.current + val density = LocalDensity.current.density + return remember { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + (rect.width() / density).dp + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt new file mode 100644 index 0000000000..c056728640 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -0,0 +1,34 @@ +package chat.simplex.common.views.chat.item + +import android.Manifest +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.FileChooserLauncher +import chat.simplex.common.platform.saveImage +import chat.simplex.common.views.helpers.withApi +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 + +@Composable +actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { + 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 -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } + else -> {} + } + showMenu.value = false + }) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt new file mode 100644 index 0000000000..d23ee58db2 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -0,0 +1,75 @@ +package chat.simplex.common.views.chat.item + +import android.os.Build +import android.view.View +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.isVisible +import chat.simplex.common.helpers.toUri +import chat.simplex.common.platform.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.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.StyledPlayerView +import dev.icerock.moko.resources.compose.stringResource +import java.net.URI + +@Composable +actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) { + // 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.toUri()).size(Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap), // show original image while it's still loading by coil + imageLoader = imageLoader + ), + contentDescription = stringResource(MR.strings.image_descr), + contentScale = ContentScale.Fit, + modifier = modifier, + ) +} + +@Composable +actual 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 + } + 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 + } + }, + modifier + ) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt new file mode 100644 index 0000000000..3dace5f9bc --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt @@ -0,0 +1,30 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import chat.simplex.common.views.newchat.ActionButton +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun ChooseAttachmentButtons(attachmentOption: MutableState, hide: () -> Unit) { + ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) { + attachmentOption.value = AttachmentOption.CameraPhoto + hide() + } + ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { + attachmentOption.value = AttachmentOption.GalleryImage + hide() + } + ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) { + attachmentOption.value = AttachmentOption.GalleryVideo + hide() + } + ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { + attachmentOption.value = AttachmentOption.File + hide() + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.android.kt new file mode 100644 index 0000000000..0b99e07582 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.android.kt @@ -0,0 +1,16 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog + +@Composable +actual fun DefaultDialog( + onDismissRequest: () -> Unit, + content: @Composable () -> Unit +) { + Dialog( + onDismissRequest = onDismissRequest + ) { + content() + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt new file mode 100644 index 0000000000..dc56ead544 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt @@ -0,0 +1,46 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties + +actual interface DefaultExposedDropdownMenuBoxScope { + @Composable + actual fun DefaultExposedDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier, + content: @Composable ColumnScope.() -> Unit + ) { + DropdownMenu(expanded, onDismissRequest, modifier, content = content) + } + + @Composable + fun DropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 0.dp), + properties: PopupProperties = PopupProperties(focusable = true), + content: @Composable ColumnScope.() -> Unit + ) { + androidx.compose.material.DropdownMenu(expanded, onDismissRequest, modifier, offset, properties, content) + } +} + +@Composable +actual fun DefaultExposedDropdownMenuBox( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier, + content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit +) { + val scope = remember { object : DefaultExposedDropdownMenuBoxScope {} } + androidx.compose.material.ExposedDropdownMenuBox(expanded, onExpandedChange, modifier, content = { + scope.content() + }) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt similarity index 69% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt index 2516bd7f82..98d1f8fb19 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt @@ -1,8 +1,7 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import android.Manifest import android.app.Activity -import android.app.Application import android.content.* import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager @@ -10,8 +9,6 @@ import android.graphics.* import android.net.Uri import android.provider.MediaStore import android.util.Base64 -import android.util.Log -import android.widget.Toast import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContract @@ -23,103 +20,35 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.ImageBitmap 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 androidx.core.content.FileProvider -import chat.simplex.app.* -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.json -import chat.simplex.app.views.chat.PickFromGallery -import chat.simplex.app.views.newchat.ActionButton +import chat.simplex.common.helpers.APPLICATION_ID +import chat.simplex.common.helpers.toURI +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.json +import chat.simplex.common.platform.* +import chat.simplex.common.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 -} +import java.net.URI 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 { - val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE) tmpFile = File.createTempFile("image", ".bmp", tmpDir) // Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system tmpFile?.deleteOnExit() ChatModel.filesToDelete.add(tmpFile!!) - uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!) + uri = FileProvider.getUriForFile(context, "$APPLICATION_ID.provider", tmpFile!!) return Intent(MediaStore.ACTION_IMAGE_CAPTURE) .putExtra(MediaStore.EXTRA_OUTPUT, uri) } @@ -201,7 +130,7 @@ fun ManagedActivityResultLauncher.launchWithFallback() { try { // Try to open any camera just to capture an image, will not be returned like with previous intent - SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) }) + androidAppContext.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) }) } catch (e: ActivityNotFoundException) { // No camera apps available at all Log.e(TAG, "Camera launcher2: " + e.stackTraceToString()) @@ -210,14 +139,15 @@ fun ManagedActivityResultLauncher.launchWithFallback() { } @Composable -fun GetImageBottomSheet( - imageBitmap: MutableState, - onImageChange: (Bitmap) -> Unit, +actual fun GetImageBottomSheet( + imageBitmap: MutableState, + onImageChange: (ImageBitmap) -> Unit, hideBottomSheet: () -> Unit ) { val context = LocalContext.current val processPickedImage = { uri: Uri? -> if (uri != null) { + val uri = uri.toURI() val bitmap = getBitmapFromUri(uri) if (bitmap != null) { imageBitmap.value = uri @@ -233,7 +163,7 @@ fun GetImageBottomSheet( cameraLauncher.launchWithFallback() hideBottomSheet() } else { - Toast.makeText(context, generalGetString(MR.strings.toast_permission_denied), Toast.LENGTH_SHORT).show() + showToast(generalGetString(MR.strings.toast_permission_denied)) } } @@ -273,3 +203,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/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.android.kt new file mode 100644 index 0000000000..b238bdf7ca --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.android.kt @@ -0,0 +1,81 @@ +package chat.simplex.common.views.helpers + +import android.os.Build.VERSION.SDK_INT +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.* +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import chat.simplex.common.platform.mainActivity +import chat.simplex.common.views.usersettings.LAMode + +actual fun authenticate( + promptTitle: String, + promptSubtitle: String, + selfDestruct: Boolean, + usingLAMode: LAMode, + completed: (LAResult) -> Unit +) { + val activity = mainActivity.get() ?: return completed(LAResult.Error("")) + when (usingLAMode) { + LAMode.SYSTEM -> when { + SDK_INT in 28..29 -> + // KeyguardManager.isDeviceSecure()? https://developer.android.com/training/sign-in/biometric-auth#declare-supported-authentication-types + authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_WEAK or DEVICE_CREDENTIAL) + SDK_INT > 29 -> + authenticateWithBiometricManager(promptTitle, promptSubtitle, activity, completed, BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + else -> completed(LAResult.Unavailable()) + } + LAMode.PASSCODE -> { + authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed) + } + } +} + +private fun authenticateWithBiometricManager( + promptTitle: String, + promptSubtitle: String, + activity: FragmentActivity, + completed: (LAResult) -> Unit, + authenticators: Int +) { + val biometricManager = BiometricManager.from(activity) + when (biometricManager.canAuthenticate(authenticators)) { + BiometricManager.BIOMETRIC_SUCCESS -> { + val executor = ContextCompat.getMainExecutor(activity) + val biometricPrompt = BiometricPrompt( + activity, + executor, + object: BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, + errString: CharSequence + ) { + super.onAuthenticationError(errorCode, errString) + completed(LAResult.Error(errString)) + } + + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + completed(LAResult.Success) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + completed(LAResult.Failed()) + } + } + ) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(promptTitle) + .setSubtitle(promptSubtitle) + .setAllowedAuthenticators(authenticators) + .setConfirmationRequired(false) + .build() + biometricPrompt.authenticate(promptInfo) + } + else -> completed(LAResult.Unavailable()) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt new file mode 100644 index 0000000000..c8ad31f760 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -0,0 +1,309 @@ +package chat.simplex.common.views.helpers + +import android.app.Application +import android.content.res.Resources +import android.graphics.* +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.media.MediaMetadataRetriever +import android.os.* +import android.provider.OpenableColumns +import android.text.Spanned +import android.text.SpannedString +import android.text.style.* +import android.util.Base64 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.* +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.text.HtmlCompat +import chat.simplex.common.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import java.io.* +import java.net.URI + +fun Spanned.toHtmlWithoutParagraphs(): String { + return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) + .substringAfter("

").substringBeforeLast("

") +} + +fun Resources.getText(id: StringResource, vararg args: Any): CharSequence { + val escapedArgs = args.map { + if (it is Spanned) it.toHtmlWithoutParagraphs() else it + }.toTypedArray() + val resource = SpannedString(getText(id)) + val htmlResource = resource.toHtmlWithoutParagraphs() + val formattedHtml = String.format(htmlResource, *escapedArgs) + return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) +} + +actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { + return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density) +} + +private fun spannableStringToAnnotatedString( + text: CharSequence, + density: Density, +): AnnotatedString { + return if (text is Spanned) { + with(density) { + buildAnnotatedString { + append((text.toString())) + text.getSpans(0, text.length, Any::class.java).forEach { + val start = text.getSpanStart(it) + val end = text.getSpanEnd(it) + when (it) { + is StyleSpan -> when (it.style) { + Typeface.NORMAL -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + ), + start, + end + ) + Typeface.BOLD -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal + ), + start, + end + ) + Typeface.ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic + ), + start, + end + ) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), + start, + end + ) + } + is TypefaceSpan -> addStyle( + SpanStyle( + fontFamily = when (it.family) { + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.Cursive.name -> FontFamily.Cursive + else -> FontFamily.Default + } + ), + start, + end + ) + is AbsoluteSizeSpan -> addStyle( + SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), + start, + end + ) + is RelativeSizeSpan -> addStyle( + SpanStyle(fontSize = it.sizeChange.em), + start, + end + ) + is StrikethroughSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.LineThrough), + start, + end + ) + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + is SuperscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Superscript), + start, + end + ) + is SubscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Subscript), + start, + end + ) + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(it.foregroundColor)), + start, + end + ) + else -> addStyle(SpanStyle(color = Color.White), start, end) + } + } + } + } + } else { + AnnotatedString(text.toString()) + } +} + +actual fun getAppFileUri(fileName: String): URI = + FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI() + +// https://developer.android.com/training/data-storage/shared/documents-files#bitmap +actual fun getLoadedImage(file: CIFile?): ImageBitmap? { + val filePath = getLoadedFilePath(file) + return if (filePath != null) { + try { + val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) + val parcelFileDescriptor = androidAppContext.contentResolver.openFileDescriptor(uri.toUri(), "r") + val fileDescriptor = parcelFileDescriptor?.fileDescriptor + val image = decodeSampledBitmapFromFileDescriptor(fileDescriptor, 1000, 1000) + parcelFileDescriptor?.close() + image.asImageBitmap() + } catch (e: Exception) { + null + } + } else { + null + } +} + +// https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap +private fun decodeSampledBitmapFromFileDescriptor(fileDescriptor: FileDescriptor?, reqWidth: Int, reqHeight: Int): Bitmap { + // First decode with inJustDecodeBounds=true to check dimensions + return BitmapFactory.Options().run { + inJustDecodeBounds = true + BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) + // Calculate inSampleSize + inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) + // Decode bitmap with inSampleSize set + inJustDecodeBounds = false + + BitmapFactory.decodeFileDescriptor(fileDescriptor, null, this) + } +} + +private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + // Raw height and width of image + val (height: Int, width: Int) = options.run { outHeight to outWidth } + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize +} + +actual fun getFileName(uri: URI): String? { + return androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + cursor.getString(nameIndex) + } +} + +actual fun getAppFilePath(uri: URI): String? { + return androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor.moveToFirst() + getAppFilePath(cursor.getString(nameIndex)) + } +} + +actual fun getFileSize(uri: URI): Long? { + return androidAppContext.contentResolver.query(uri.toUri(), null, null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + cursor.getLong(sizeIndex) + } +} + +actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? { + return if (Build.VERSION.SDK_INT >= 28) { + val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri()) + try { + ImageDecoder.decodeBitmap(source) + } catch (e: android.graphics.ImageDecoder.DecodeException) { + Log.e(TAG, "Unable to decode the image: ${e.stackTraceToString()}") + if (withAlertOnException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.image_decoding_exception_title), + text = generalGetString(MR.strings.image_decoding_exception_desc) + ) + } + null + } + } else { + BitmapFactory.decodeFile(getAppFilePath(uri)) + }?.asImageBitmap() +} + +actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? { + return if (Build.VERSION.SDK_INT >= 28) { + val source = ImageDecoder.createSource(androidAppContext.contentResolver, uri.toUri()) + try { + ImageDecoder.decodeDrawable(source) + } catch (e: android.graphics.ImageDecoder.DecodeException) { + if (withAlertOnException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.image_decoding_exception_title), + text = generalGetString(MR.strings.image_decoding_exception_desc) + ) + } + Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}") + null + } + } else { + Drawable.createFromPath(getAppFilePath(uri)) + } +} + +actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? { + return try { + val ext = if (asPng) "png" else "jpg" + return File(tmpDir.absolutePath + File.separator + generateNewFileName("IMG", ext)).apply { + outputStream().use { out -> + image.asAndroidBitmap().compress(if (asPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG, 85, out) + out.flush() + } + deleteOnExit() + ChatModel.filesToDelete.add(this) + } + } catch (e: Exception) { + Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}") + null + } +} + +actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { + val mmr = MediaMetadataRetriever() + mmr.setDataSource(androidAppContext, uri.toUri()) + val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() + val image = when { + timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST) + random -> mmr.frameAtTime + else -> mmr.getFrameAtTime(0) + } + mmr.release() + return VideoPlayerInterface.PreviewAndDuration(image?.asImageBitmap(), durationMs, timestamp ?: 0) +} + +actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encodeToString(this, Base64.DEFAULT) + +actual fun String.toByteArrayFromBase64ForPassphrase(): ByteArray = Base64.decode(this, Base64.DEFAULT) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt similarity index 90% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ConnectViaLinkView.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt index 15ada1e103..dcb5055425 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ConnectViaLinkView.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.android.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -8,16 +8,11 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel +import chat.simplex.common.model.ChatModel import chat.simplex.res.MR -enum class ConnectViaLinkTab { - SCAN, PASTE -} - @Composable -fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { +actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { val selection = remember { mutableStateOf( runCatching { ConnectViaLinkTab.valueOf(m.controller.appPrefs.connectViaLinkTab.get()!!) }.getOrDefault(ConnectViaLinkTab.SCAN) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCode.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCode.android.kt new file mode 100644 index 0000000000..eee525827e --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCode.android.kt @@ -0,0 +1,18 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.ui.graphics.* + +actual fun ImageBitmap.replaceColor(from: Int, to: Int): ImageBitmap { + val pixels = IntArray(width * height) + val bitmap = this.asAndroidBitmap() + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + var i = 0 + while (i < pixels.size) { + if (pixels[i] == from) { + pixels[i] = to + } + i++ + } + bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + return bitmap.asImageBitmap() +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 4dcb4a0ade..7fb6445d57 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCodeScanner.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import android.annotation.SuppressLint import android.util.Log @@ -18,14 +18,14 @@ import boofcv.alg.color.ColorFormat import boofcv.android.ConvertCameraImage import boofcv.factory.fiducial.FactoryFiducial import boofcv.struct.image.GrayU8 -import chat.simplex.app.TAG +import chat.simplex.common.platform.TAG import com.google.common.util.concurrent.ListenableFuture import java.util.concurrent.* // Adapted from learntodroid - https://gist.github.com/learntodroid/8f839be0b29d0378f843af70607bd7f5 @Composable -fun QRCodeScanner(onBarcode: (String) -> Unit) { +actual fun QRCodeScanner(onBarcode: (String) -> Unit) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var preview by remember { mutableStateOf(null) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt new file mode 100644 index 0000000000..ee825730ca --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt @@ -0,0 +1,19 @@ +package chat.simplex.common.views.newchat + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import chat.simplex.common.model.ChatModel +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + ConnectContactLayout( + chatModelIncognito = chatModel.incognito.value, + close + ) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt new file mode 100644 index 0000000000..0944ff4aab --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.android.kt @@ -0,0 +1,26 @@ +package chat.simplex.common.views.onboarding + +import android.Manifest +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import chat.simplex.common.platform.ntfManager +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +actual fun SetNotificationsModeAdditions() { + if (Build.VERSION.SDK_INT >= 33) { + val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + LaunchedEffect(notificationsPermissionState.hasPermission) { + if (notificationsPermissionState.hasPermission) { + ntfManager.createNtfChannelsMaybeShowAlert() + } else { + notificationsPermissionState.launchPermissionRequest() + } + } + } else { + LaunchedEffect(Unit) { + ntfManager.createNtfChannelsMaybeShowAlert() + } + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt new file mode 100644 index 0000000000..e03e19858a --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -0,0 +1,169 @@ +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionView +import android.app.Activity +import android.content.ComponentName +import android.content.pm.PackageManager +import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT +import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.* +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import chat.simplex.common.R +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* +import chat.simplex.common.helpers.APPLICATION_ID +import chat.simplex.common.helpers.saveAppLocale +import chat.simplex.common.views.usersettings.AppearanceScope.ColorEditor +import chat.simplex.res.MR +import kotlinx.coroutines.delay + +enum class AppIcon(val resId: Int) { + DEFAULT(R.drawable.icon_round_common), + DARK_BLUE(R.drawable.icon_dark_blue_round_common), +} + +@Composable +actual fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + val appIcon = remember { mutableStateOf(findEnabledIcon()) } + + fun setAppIcon(newIcon: AppIcon) { + if (appIcon.value == newIcon) return + val newComponent = ComponentName(APPLICATION_ID, "chat.simplex.app.MainActivity_${newIcon.name.lowercase()}") + val oldComponent = ComponentName(APPLICATION_ID, "chat.simplex.app.MainActivity_${appIcon.value.name.lowercase()}") + androidAppContext.packageManager.setComponentEnabledSetting( + newComponent, + COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP + ) + + androidAppContext.packageManager.setComponentEnabledSetting( + oldComponent, + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP + ) + + appIcon.value = newIcon + } + + AppearanceScope.AppearanceLayout( + appIcon, + m.controller.appPrefs.appLanguage, + m.controller.appPrefs.systemDarkTheme, + changeIcon = ::setAppIcon, + showSettingsModal = showSettingsModal, + editColor = { name, initialColor -> + ModalManager.shared.showModalCloseable { close -> + ColorEditor(name, initialColor, close) + } + }, + ) +} + +@Composable +fun AppearanceScope.AppearanceLayout( + icon: MutableState, + languagePref: SharedPreference, + systemDarkTheme: SharedPreference, + changeIcon: (AppIcon) -> Unit, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + editColor: (ThemeColor, Color) -> Unit, +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + AppBarTitle(stringResource(MR.strings.appearance_settings)) + SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) { + val context = LocalContext.current + // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // SectionItemWithValue( + // generalGetString(MR.strings.settings_section_title_language).lowercase().replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() }, + // remember { mutableStateOf("system") }, + // listOf(ValueTitleDesc("system", generalGetString(MR.strings.change_verb), "")), + // onSelected = { openSystemLangPicker(context as? Activity ?: return@SectionItemWithValue) } + // ) + // } else { + val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") } + LangSelector(state) { + state.value = it + withApi { + delay(200) + val activity = context as? Activity + if (activity != null) { + if (it == "system") { + activity.saveAppLocale(languagePref) + } else { + activity.saveAppLocale(languagePref, it) + } + } + } + } + // } + } + SectionDividerSpaced() + + SectionView(stringResource(MR.strings.settings_section_title_icon), padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { + LazyRow { + items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index -> + val item = AppIcon.values()[index] + val mipmap = ContextCompat.getDrawable(LocalContext.current, item.resId)!! + Image( + bitmap = mipmap.toBitmap().asImageBitmap(), + contentDescription = "", + contentScale = ContentScale.Fit, + modifier = Modifier + .shadow(if (item == icon.value) 1.dp else 0.dp, ambientColor = colors.secondaryVariant) + .size(70.dp) + .clickable { changeIcon(item) } + .padding(10.dp) + ) + + if (index + 1 != AppIcon.values().size) { + Spacer(Modifier.padding(horizontal = 4.dp)) + } + } + } + } + + SectionDividerSpaced(maxTopPadding = true) + ThemesSection(systemDarkTheme, showSettingsModal, editColor) + SectionBottomSpacer() + } +} + +private fun findEnabledIcon(): AppIcon = AppIcon.values().first { icon -> + androidAppContext.packageManager.getComponentEnabledSetting( + ComponentName(APPLICATION_ID, "chat.simplex.app.MainActivity_${icon.name.lowercase()}") + ).let { it == COMPONENT_ENABLED_STATE_DEFAULT || it == COMPONENT_ENABLED_STATE_ENABLED } +} + +@Preview +@Composable +fun PreviewAppearanceSettings() { + SimpleXTheme { + AppearanceScope.AppearanceLayout( + icon = remember { mutableStateOf(AppIcon.DARK_BLUE) }, + languagePref = SharedPreference({ null }, {}), + systemDarkTheme = SharedPreference({ null }, {}), + changeIcon = {}, + showSettingsModal = { {} }, + editColor = { _, _ -> }, + ) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt new file mode 100644 index 0000000000..3c1771c8b3 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt @@ -0,0 +1,32 @@ +package chat.simplex.common.views.usersettings + +import SectionView +import android.view.WindowManager +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity +import chat.simplex.common.model.ChatModel +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun PrivacyDeviceSection( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + setPerformLA: (Boolean) -> Unit, +) { + SectionView(stringResource(MR.strings.settings_section_title_device)) { + ChatLockItem(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) + } + } + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt new file mode 100644 index 0000000000..a1b7b3141d --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.android.kt @@ -0,0 +1,16 @@ +package chat.simplex.common.views.usersettings + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import chat.simplex.common.model.ServerCfg +import com.google.accompanist.permissions.rememberPermissionState + +@Composable +actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { + val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + LaunchedEffect(Unit) { + cameraPermissionState.launchPermissionRequest() + } + ScanProtocolServerLayout(onNext) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt new file mode 100644 index 0000000000..49a29cf141 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -0,0 +1,49 @@ +package chat.simplex.common.views.usersettings + +import SectionView +import androidx.compose.runtime.Composable +import androidx.work.WorkManager +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.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 +actual fun SettingsSectionApp( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), + showVersion: () -> Unit, + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit +) { + 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) + } +} + + +private fun restartApp() { + ProcessPhoenix.triggerRebirth(androidAppContext) + shutdownApp() +} + +private fun shutdownApp() { + WorkManager.getInstance(androidAppContext).cancelAllWork() + platform.androidServiceSafeStop() + Runtime.getRuntime().exit(0) +} + +private fun shutdownAppAlert(onConfirm: () -> Unit) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.shutdown_alert_question), + text = generalGetString(MR.strings.shutdown_alert_desc), + destructive = true, + onConfirm = onConfirm + ) +} diff --git a/apps/multiplatform/android/src/main/res/drawable/edit_text_cursor.xml b/apps/multiplatform/common/src/androidMain/res/drawable/edit_text_cursor.xml similarity index 100% rename from apps/multiplatform/android/src/main/res/drawable/edit_text_cursor.xml rename to apps/multiplatform/common/src/androidMain/res/drawable/edit_text_cursor.xml 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..fe8dcbd536 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_common_platform_CoreKt_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_common_platform_CoreKt_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_common_platform_CoreKt_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_common_platform_CoreKt_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_common_platform_CoreKt_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_common_platform_CoreKt_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_common_platform_CoreKt_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_common_platform_CoreKt_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_common_platform_CoreKt_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)); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index e772adffa2..2f7299393c 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -15,7 +15,7 @@ void hs_init(int * argc, char **argv[]); //extern void reallocarray(void){}; JNIEXPORT void JNICALL -Java_chat_simplex_common_platform_BackendKt_initHS(JNIEnv *env, jclass clazz) { +Java_chat_simplex_common_platform_CoreKt_initHS(JNIEnv *env, jclass clazz) { hs_init(NULL, NULL); } @@ -31,7 +31,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_common_platform_BackendKt_chatMigrateInit(JNIEnv *env, jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) { +Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, 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); @@ -54,7 +54,7 @@ Java_chat_simplex_common_platform_BackendKt_chatMigrateInit(JNIEnv *env, jclass } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_BackendKt_chatSendCmd(JNIEnv *env, jclass clazz, jlong controller, jstring msg) { +Java_chat_simplex_common_platform_CoreKt_chatSendCmd(JNIEnv *env, 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); @@ -62,17 +62,17 @@ Java_chat_simplex_common_platform_BackendKt_chatSendCmd(JNIEnv *env, jclass claz } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_BackendKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) { +Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, jclass clazz, jlong controller) { return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_BackendKt_chatRecvMsgWait(JNIEnv *env, jclass clazz, jlong controller, jint wait) { +Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, jclass clazz, jlong controller, jint wait) { return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait)); } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_BackendKt_chatParseMarkdown(JNIEnv *env, jclass clazz, jstring str) { +Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, 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); @@ -80,7 +80,7 @@ Java_chat_simplex_common_platform_BackendKt_chatParseMarkdown(JNIEnv *env, jclas } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_BackendKt_chatParseServer(JNIEnv *env, jclass clazz, jstring str) { +Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, 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); @@ -88,7 +88,7 @@ Java_chat_simplex_common_platform_BackendKt_chatParseServer(JNIEnv *env, jclass } JNIEXPORT jstring JNICALL -Java_chat_simplex_common_platform_BackendKt_chatPasswordHash(JNIEnv *env, jclass clazz, jstring pwd, jstring salt) { +Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, 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)); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt new file mode 100644 index 0000000000..c2ff62442a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -0,0 +1,217 @@ +package chat.simplex.common + +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.common.views.usersettings.SetDeliveryReceiptsView +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.SimpleButton +import chat.simplex.common.views.SplashView +import chat.simplex.common.views.call.ActiveCallView +import chat.simplex.common.views.call.IncomingCallAlertView +import chat.simplex.common.views.chat.ChatView +import chat.simplex.common.views.chatlist.ChatListView +import chat.simplex.common.views.chatlist.ShareListView +import chat.simplex.common.views.database.DatabaseErrorView +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.* +import chat.simplex.common.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 + +@Composable +fun AppScreen() { + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { + Surface(color = MaterialTheme.colors.background) { + MainScreen() + } + } +} + +@Composable +fun MainScreen() { + val chatModel = ChatModel + 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 + ) { + AppLock.showLANotice(ChatModel.controller.appPrefs.laNoticeShown) } + } + 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 = { + AppLock.laFailed.value = false + AppLock.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() + } + ) { + if (chatModel.setDeliveryReceipts.value) { + SetDeliveryReceiptsView(chatModel) + } else { + val stopped = chatModel.chatRunning.value == false + if (chatModel.sharedContent.value == null) + ChatListView(chatModel, AppLock::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 { AppLock.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 (!(AppLock.destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { + AppLock.runAuthenticate() + } + } + if (chatModel.controller.appPrefs.performLA.get() && AppLock.laFailed.value) { + AuthView() + } else { + SplashView() + } + } else if (chatModel.showCallView.value) { + ActiveCallView() + } + 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 = AppLock.elapsedRealtime() + 3000 + } + } +} + +@Composable +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/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt new file mode 100644 index 0000000000..c66eeefd59 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -0,0 +1,269 @@ +package chat.simplex.common + +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.common.model.* +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.localauth.SetAppPasscodeView +import chat.simplex.common.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 = ChatController.appPrefs + 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 appPrefs = ChatController.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 = !ChatController.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) { + ChatController.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() + } + } + } + ) + } + + fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000 + + fun recheckAuthState() { + val enteredBackgroundVal = enteredBackground.value + val delay = ChatController.appPrefs.laLockDelay.get() + if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) { + if (userAuthorized.value != false) { + /** [runAuthenticate] will be called in [MainScreen] if needed. Making like this prevents double showing of passcode on start */ + setAuthState() + } else if (!ChatModel.activeCallViewIsVisible.value) { + runAuthenticate() + } + } + } + fun appWasHidden() { + enteredBackground.value = elapsedRealtime() + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt similarity index 98% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 1a76510a0b..ea44745b39 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1,19 +1,18 @@ -package chat.simplex.app.model +package chat.simplex.common.model -import android.net.Uri import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.call.* -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.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.platform.AudioPlayer import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -26,6 +25,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import java.io.File +import java.net.URI import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.* @@ -66,14 +66,21 @@ object ChatModel { val clearOverlays = mutableStateOf(false) // set when app is opened via contact or invitation URI - val appOpenUrl = mutableStateOf(null) + val appOpenUrl = mutableStateOf(null) // preferences - val notificationsMode by lazy { mutableStateOf(NotificationsMode.values().firstOrNull { it.name == controller.appPrefs.notificationsMode.get() } ?: NotificationsMode.default) } - val notificationPreviewMode by lazy { mutableStateOf(NotificationPreviewMode.values().firstOrNull { it.name == controller.appPrefs.notificationPreviewMode.get() } ?: NotificationPreviewMode.default) } - val performLA by lazy { mutableStateOf(controller.appPrefs.performLA.get()) } + val notificationPreviewMode by lazy { + mutableStateOf( + try { + NotificationPreviewMode.valueOf(controller.appPrefs.notificationPreviewMode.get()!!) + } catch (e: Exception) { + NotificationPreviewMode.default + } + ) + } + val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) } val showAdvertiseLAUnavailableAlert = mutableStateOf(false) - val incognito by lazy { mutableStateOf(controller.appPrefs.incognito.get()) } + val incognito by lazy { mutableStateOf(ChatController.appPrefs.incognito.get()) } // current WebRTC call val callManager = CallManager(this) @@ -95,7 +102,7 @@ object ChatModel { val sharedContent = mutableStateOf(null as SharedContent?) val filesToDelete = mutableSetOf() - val simplexLinkMode by lazy { mutableStateOf(controller.appPrefs.simplexLinkMode.get()) } + val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value @@ -2490,3 +2497,11 @@ data class ChatItemVersion( val itemVersionTs: Instant, val createdAt: Instant, ) + +enum class NotificationPreviewMode { + MESSAGE, CONTACT, HIDDEN; + + companion object { + val default: NotificationPreviewMode = MESSAGE + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt similarity index 98% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 281b630109..065003a072 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1,21 +1,21 @@ -package chat.simplex.app.model +package chat.simplex.common.model -import android.content.* -import android.util.Log -import chat.simplex.app.views.helpers.* +import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* 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.ui.theme.* -import chat.simplex.app.views.call.* -import chat.simplex.app.views.newchat.ConnectViaLinkTab -import chat.simplex.app.views.onboarding.OnboardingStage -import chat.simplex.app.views.usersettings.* +import chat.simplex.common.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.* +import chat.simplex.common.views.newchat.ConnectViaLinkTab +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.views.usersettings.* import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR +import com.russhwolf.settings.Settings import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -43,19 +43,17 @@ enum class SimplexLinkMode { BROWSER; companion object { - val default = SimplexLinkMode.DESCRIPTION + val default = DESCRIPTION } } class AppPreferences { - private val sharedPreferences: SharedPreferences = SimplexApp.context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE) - private val sharedPreferencesThemes: SharedPreferences = SimplexApp.context.getSharedPreferences(SHARED_PREFS_THEMES_ID, Context.MODE_PRIVATE) - // deprecated, remove in 2024 private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) - val notificationsMode = mkStrPreference(SHARED_PREFS_NOTIFICATIONS_MODE, - if (!runServiceInBackground.get()) NotificationsMode.OFF.name else NotificationsMode.default.name - ) + val notificationsMode = mkEnumPreference( + SHARED_PREFS_NOTIFICATIONS_MODE, + if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default + ) { NotificationsMode.values().firstOrNull { it.name == this } } val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false) @@ -142,7 +140,7 @@ class AppPreferences { val initializationVectorAppPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_APP_PASSPHRASE, null) val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null) val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null) - val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null, true) + val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null) val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false) val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false) val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null) @@ -153,7 +151,7 @@ class AppPreferences { json.encodeToString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it) }, decode = { json.decodeFromString(MapSerializer(String.serializer(), ThemeOverrides.serializer()), it) - }, sharedPreferencesThemes) + }, settingsThemes) val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null) val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0) @@ -161,65 +159,73 @@ class AppPreferences { private fun mkIntPreference(prefName: String, default: Int) = SharedPreference( - get = fun() = sharedPreferences.getInt(prefName, default), - set = fun(value) = sharedPreferences.edit().putInt(prefName, value).apply() + get = fun() = settings.getInt(prefName, default), + set = fun(value) = settings.putInt(prefName, value) ) private fun mkLongPreference(prefName: String, default: Long) = SharedPreference( - get = fun() = sharedPreferences.getLong(prefName, default), - set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply() + get = fun() = settings.getLong(prefName, default), + set = fun(value) = settings.putLong(prefName, value) ) private fun mkTimeoutPreference(prefName: String, default: Long, proxyDefault: Long): SharedPreference { val d = if (networkUseSocksProxy.get()) proxyDefault else default return SharedPreference( - get = fun() = sharedPreferences.getLong(prefName, d), - set = fun(value) = sharedPreferences.edit().putLong(prefName, value).apply() + get = fun() = settings.getLong(prefName, d), + set = fun(value) = settings.putLong(prefName, value) ) } private fun mkBoolPreference(prefName: String, default: Boolean) = SharedPreference( - get = fun() = sharedPreferences.getBoolean(prefName, default), - set = fun(value) = sharedPreferences.edit().putBoolean(prefName, value).apply() + get = fun() = settings.getBoolean(prefName, default), + set = fun(value) = settings.putBoolean(prefName, value) ) private fun mkStrPreference(prefName: String, default: String?): SharedPreference = SharedPreference( - get = fun() = sharedPreferences.getString(prefName, default), - set = fun(value) = sharedPreferences.edit().putString(prefName, value).apply() + get = { + val nullValue = "----------------------" + val pref = settings.getString(prefName, default ?: nullValue) + if (pref != nullValue) { + pref + } else { + null + } + }, + set = fun(value) = if (value != null) settings.putString(prefName, value) else settings.remove(prefName) ) private fun mkEnumPreference(prefName: String, default: T, construct: String.() -> T?): SharedPreference = SharedPreference( - get = fun() = sharedPreferences.getString(prefName, default.toString())?.construct() ?: default, - set = fun(value) = sharedPreferences.edit().putString(prefName, value.toString()).apply() + get = fun() = settings.getString(prefName, default.toString()).construct() ?: default, + set = fun(value) = settings.putString(prefName, value.toString()) ) - /** - * Provide `[commit] = true` to save preferences right now, not after some unknown period of time. - * So in case of a crash this value will be saved 100% - * */ - private fun mkDatePreference(prefName: String, default: Instant?, commit: Boolean = false): SharedPreference = + // LALAL + private fun mkDatePreference(prefName: String, default: Instant?): SharedPreference = SharedPreference( get = { - val pref = sharedPreferences.getString(prefName, default?.toEpochMilliseconds()?.toString()) - pref?.let { Instant.fromEpochMilliseconds(pref.toLong()) } + val nullValue = "----------------------" + val pref = settings.getString(prefName, default?.toEpochMilliseconds()?.toString() ?: nullValue) + if (pref != nullValue) { + Instant.fromEpochMilliseconds(pref.toLong()) + } else { + null + } }, - set = fun(value) = sharedPreferences.edit().putString(prefName, value?.toEpochMilliseconds()?.toString()).let { - if (commit) it.commit() else it.apply() - } + set = fun(value) = if (value?.toEpochMilliseconds() != null) settings.putString(prefName, value.toEpochMilliseconds().toString()) else settings.remove(prefName) ) - private fun mkMapPreference(prefName: String, default: Map, encode: (Map) -> String, decode: (String) -> Map, prefs: SharedPreferences = sharedPreferences): SharedPreference> = + private fun mkMapPreference(prefName: String, default: Map, encode: (Map) -> String, decode: (String) -> Map, prefs: Settings = settings): SharedPreference> = SharedPreference( - get = fun() = decode(prefs.getString(prefName, encode(default))!!), - set = fun(value) = prefs.edit().putString(prefName, encode(value)).apply() + get = fun() = decode(prefs.getString(prefName, encode(default))), + set = fun(value) = prefs.putString(prefName, encode(value)) ) companion object { - internal const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" + const val SHARED_PREFS_ID = "chat.simplex.app.SIMPLEX_APP_PREFS" internal const val SHARED_PREFS_THEMES_ID = "chat.simplex.app.THEMES" private const val SHARED_PREFS_AUTO_RESTART_WORKER_VERSION = "AutoRestartWorkerVersion" private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground" @@ -240,7 +246,7 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" - internal const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" + const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" @@ -294,7 +300,6 @@ private const val MESSAGE_TIMEOUT: Int = 15_000_000 object ChatController { var ctrl: ChatCtrl? = -1 val appPrefs: AppPreferences by lazy { AppPreferences() } - val ntfManager by lazy { NtfManager } val chatModel = ChatModel private var receiverStarted = false @@ -316,8 +321,8 @@ object ChatController { try { if (chatModel.chatRunning.value == true) return apiSetNetworkConfig(getNetCfg()) - apiSetTempFolder(getTempFilesDirectory()) - apiSetFilesFolder(getAppFilesDirectory()) + apiSetTempFolder(coreTmpDir.absolutePath) + apiSetFilesFolder(appFilesDir.absolutePath) apiSetXFTPConfig(getXFTPCfg()) val justStarted = apiStartChat() val users = listUsers() @@ -328,7 +333,7 @@ object ChatController { chatModel.userCreated.value = true apiSetIncognito(chatModel.incognito.value) getUserChatData() - chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now()) + appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true startReceiver() Log.d(TAG, "startChat: started") @@ -966,7 +971,8 @@ object ChatController { val r = sendCmd(CC.ApiShowMyAddress(userId)) if (r is CR.UserContactLink) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore - && r.chatError.storeError is StoreError.UserContactLinkNotFound) { + && r.chatError.storeError is StoreError.UserContactLinkNotFound + ) { return null } Log.e(TAG, "apiGetUserAddress bad response: ${r.responseType} ${r.details}") @@ -978,7 +984,8 @@ object ChatController { val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept)) if (r is CR.UserContactLinkUpdated) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore - && r.chatError.storeError is StoreError.UserContactLinkNotFound) { + && r.chatError.storeError is StoreError.UserContactLinkNotFound + ) { return null } Log.e(TAG, "userAddressAutoAccept bad response: ${r.responseType} ${r.details}") @@ -1407,7 +1414,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 && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } @@ -1560,7 +1567,7 @@ object ChatController { // TODO check encryption is compatible withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey) - val useRelay = chatModel.controller.appPrefs.webrtcPolicyRelay.get() + val useRelay = appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, ".callOffer iceServers $iceServers") chatModel.callCommand.value = WCallCommand.Offer( @@ -3952,3 +3959,12 @@ 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() { + OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; + + companion object { + val default: NotificationsMode = SERVICE + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt new file mode 100644 index 0000000000..6e410cc2c3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -0,0 +1,47 @@ +package chat.simplex.common.platform + +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.ChatController +import chat.simplex.common.ui.theme.DefaultTheme +import java.util.* + +enum class AppPlatform { + ANDROID, DESKTOP; + + val isAndroid: Boolean + get() = this == ANDROID +} + +expect val appPlatform: AppPlatform + +val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) + BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE +else + BuildConfigCommon.DESKTOP_VERSION_NAME to null + +expect fun initHaskell() + +class FifoQueue(private var capacity: Int) : LinkedList() { + override fun add(element: E): Boolean { + if(size > capacity) removeFirst() + return super.add(element) + } +} + +// LALAL VERSION CODE +fun runMigrations() { + val lastMigration = ChatController.appPrefs.lastMigratedVersionCode + if (lastMigration.get() < BuildConfigCommon.ANDROID_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(BuildConfigCommon.ANDROID_VERSION_CODE) + break + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Back.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Back.kt new file mode 100644 index 0000000000..6096cc3952 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Back.kt @@ -0,0 +1,7 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* + +@SuppressWarnings("MissingJvmstatic") +@Composable +expect fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt new file mode 100644 index 0000000000..c39c000807 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -0,0 +1,70 @@ +package chat.simplex.common.platform + +import chat.simplex.common.model.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.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 confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp + val migrated: Array = chatMigrateInit(dbAbsolutePrefixPath, 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.controller.appPrefs.privacyDeliveryReceiptsSet.set(true) + 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 + } + if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete && !chatModel.controller.appPrefs.privacyDeliveryReceiptsSet.get()) { + chatModel.setDeliveryReceipts.value = true + } + chatController.startChat(user) + platform.androidChatInitializedAndStarted() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt new file mode 100644 index 0000000000..5af941fb42 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.platform + +interface CryptorInterface { + fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? + fun encryptText(text: String, alias: String): Pair + fun deleteKey(alias: String) +} + +expect val cryptor: CryptorInterface diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt new file mode 100644 index 0000000000..b02c86579f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -0,0 +1,85 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.Composable +import chat.simplex.common.model.CIFile +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import java.io.* +import java.net.URI + +expect val dataDir: File +expect val tmpDir: File +expect val filesDir: File +expect val appFilesDir: File +expect val coreTmpDir: File +expect val dbAbsolutePrefixPath: String + +expect val chatDatabaseFileName: String +expect val agentDatabaseFileName: String + +/** +* This is used only for temporary storing db archive for export. +* Providing [tmpDir] instead crashes the app. Check db export before moving from this path to something else +* */ +expect val databaseExportDir: File + +fun copyFileToFile(from: File, to: URI, finally: () -> Unit) { + try { + to.outputStream().use { stream -> + BufferedOutputStream(stream).use { outputStream -> + from.inputStream().use { it.copyTo(outputStream) } + } + } + showToast(generalGetString(MR.strings.file_saved)) + } catch (e: Error) { + showToast(generalGetString(MR.strings.error_saving_file)) + Log.e(TAG, "copyFileToFile error saving file $e") + } finally { + finally() + } +} + +fun copyBytesToFile(bytes: ByteArrayInputStream, to: URI, finally: () -> Unit) { + try { + to.outputStream().use { stream -> + BufferedOutputStream(stream).use { outputStream -> + bytes.use { it.copyTo(outputStream) } + } + } + showToast(generalGetString(MR.strings.file_saved)) + } catch (e: Error) { + showToast(generalGetString(MR.strings.error_saving_file)) + Log.e(TAG, "copyBytesToFile error saving file $e") + } finally { + finally() + } +} + +fun getAppFilePath(fileName: String): String { + return appFilesDir.absolutePath + File.separator + 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 + } +} + +@Composable +expect fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher + +expect fun rememberFileChooserMultipleLauncher(onResult: (List) -> Unit): FileChooserMultipleLauncher + +expect class FileChooserLauncher() { + suspend fun launch(input: String) +} + +expect class FileChooserMultipleLauncher() { + suspend fun launch(input: String) +} + +expect fun URI.inputStream(): InputStream? +expect fun URI.outputStream(): OutputStream diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt new file mode 100644 index 0000000000..e73786b2e1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -0,0 +1,24 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.graphics.ImageBitmap +import boofcv.struct.image.GrayU8 +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.URI + +expect fun base64ToBitmap(base64ImageString: String): ImageBitmap +expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String +expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream +expect fun cropToSquare(image: ImageBitmap): ImageBitmap +expect fun compressImageStr(bitmap: ImageBitmap): String +expect fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream + +expect fun GrayU8.toImageBitmap(): ImageBitmap + +expect fun ImageBitmap.addLogo(): ImageBitmap +expect fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap + +expect fun isImage(uri: URI): Boolean +expect fun isAnimImage(uri: URI, drawable: Any?): Boolean + +expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Log.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Log.kt new file mode 100644 index 0000000000..a1b39527d1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Log.kt @@ -0,0 +1,10 @@ +package chat.simplex.common.platform + +const val TAG = "SIMPLEX" + +expect object Log { + fun d(tag: String, text: String) + fun e(tag: String, text: String) + fun i(tag: String, text: String) + fun w(tag: String, text: String) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt new file mode 100644 index 0000000000..a5c455d989 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -0,0 +1,13 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +expect fun Modifier.navigationBarsWithImePadding(): Modifier + +@Composable +expect fun ProvideWindowInsets( + consumeWindowInsets: Boolean = true, + windowInsetsAnimationsEnabled: Boolean = true, + content: @Composable () -> Unit +) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt new file mode 100644 index 0000000000..14d6f1701e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt @@ -0,0 +1,3 @@ +package chat.simplex.common.platform + +expect fun allowedToShowNotification(): Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt new file mode 100644 index 0000000000..1a722950ce --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -0,0 +1,23 @@ +package chat.simplex.common.platform + +import chat.simplex.common.model.* +import chat.simplex.common.views.call.RcvCallInvitation + +enum class NotificationAction { + ACCEPT_CONTACT_REQUEST +} + +lateinit var ntfManager: NtfManager + +abstract class NtfManager { + abstract fun notifyContactConnected(user: User, contact: Contact) + abstract fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) + abstract fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) + abstract fun notifyCallInvitation(invitation: RcvCallInvitation) + abstract fun hasNotificationsForChat(chatId: String): Boolean + abstract fun cancelNotificationsForChat(chatId: String) + abstract fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) + abstract fun createNtfChannelsMaybeShowAlert() + abstract fun cancelCallNotification() + abstract fun cancelAllNotifications() +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt new file mode 100644 index 0000000000..eeea2d08fc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -0,0 +1,26 @@ +package chat.simplex.common.platform + +import chat.simplex.common.model.NotificationsMode + +interface PlatformInterface { + suspend fun androidServiceStart() {} + fun androidServiceSafeStop() {} + fun androidNotificationsModeChanged(mode: NotificationsMode) {} + fun androidChatStartedAfterBeingOff() {} + fun androidChatStopped() {} + fun androidChatInitializedAndStarted() {} +} +/** + * Multiplatform project has separate directories per platform + common directory that contains directories per platform + common for all of them. + * This means that we can not call code from `android` directory via code from `common/androidMain` directory. So this is a way to do it: + * - we specify interface that should be implemented by platforms + * - platforms made its implementation by assigning it to this variable at runtime + * - common code calls this variable and everything works as expected. + * + * Functions that expected to be used on only one platform, should be prefixed with platform name, like androidSomething. It helps + * to identify it's use-case. Easy to understand that it is only needed on one specific platform. Functions without prefixes are used on + * more than one platform. + * + * See [SimplexApp] and [AppCommon.desktop] for re-assigning of this var + * */ +var platform: PlatformInterface = object : PlatformInterface {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt new file mode 100644 index 0000000000..6d60703e53 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -0,0 +1,15 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.text.TextStyle +import chat.simplex.common.views.chat.ComposeState + +@Composable +expect fun PlatformTextField( + composeState: MutableState, + textStyle: MutableState, + showDeleteTextButton: MutableState, + userIsObserver: Boolean, + onMessageChange: (String) -> Unit +) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt new file mode 100644 index 0000000000..be2c87a2fc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt @@ -0,0 +1,42 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.MutableState +import chat.simplex.common.model.ChatItem +import kotlinx.coroutines.CoroutineScope + +interface RecorderInterface { + companion object { + // Allows to stop the recorder from outside without having the recorder in a variable + var stopRecording: (() -> Unit)? = null + val extension: String = "m4a" + } + fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String + fun stop(): Int +} + +expect class RecorderNative: RecorderInterface + +interface AudioPlayerInterface { + fun play( + filePath: String?, + audioPlaying: MutableState, + progress: MutableState, + duration: MutableState, + resetOnEnd: Boolean, + ) + fun stop() + fun stop(item: ChatItem) + fun stop(fileName: String?) + fun pause(audioPlaying: MutableState, pro: MutableState) + fun seekTo(ms: Int, pro: MutableState, filePath: String?) + fun duration(filePath: String): Int? +} + +expect object AudioPlayer: AudioPlayerInterface + +interface SoundPlayerInterface { + fun start(scope: CoroutineScope, sound: Boolean) + fun stop() +} + +expect object SoundPlayer: SoundPlayerInterface diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Resources.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Resources.kt new file mode 100644 index 0000000000..3d31faaa84 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Resources.kt @@ -0,0 +1,31 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import com.russhwolf.settings.Settings +import dev.icerock.moko.resources.StringResource + +@Composable +expect fun font(name: String, res: String, weight: FontWeight = FontWeight.Normal, style: FontStyle = FontStyle.Normal): Font + +expect fun StringResource.localized(): String + +// Non-@Composable implementation +expect fun isInNightMode(): Boolean + +expect val settings: Settings +expect val settingsThemes: Settings + +enum class ScreenOrientation { + UNDEFINED, PORTRAIT, LANDSCAPE +} + +expect fun screenOrientation(): ScreenOrientation + +@Composable +expect fun screenWidth(): Dp + +expect fun isRtl(text: CharSequence): Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt new file mode 100644 index 0000000000..03ad4b5441 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.UriHandler + +expect fun UriHandler.sendEmail(subject: String, body: CharSequence) + +expect fun ClipboardManager.shareText(text: String) +expect fun shareFile(text: String, filePath: String) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt new file mode 100644 index 0000000000..38629f038b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt @@ -0,0 +1,16 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* +import chat.simplex.common.views.helpers.KeyboardState + +expect fun showToast(text: String, timeout: Long = 2500L) + +@Composable +expect fun LockToCurrentOrientationUntilDispose() + +@Composable +expect fun LocalMultiplatformView(): Any? + +@Composable +expect fun getKeyboardState(): State +expect fun hideKeyboard(view: Any?) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt new file mode 100644 index 0000000000..bde9d8a49d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt @@ -0,0 +1,37 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.MutableState +import androidx.compose.ui.graphics.ImageBitmap +import java.net.URI + +interface VideoPlayerInterface { + data class PreviewAndDuration(val preview: ImageBitmap?, val duration: Long?, val timestamp: Long) + + val soundEnabled: MutableState + val brokenVideo: MutableState + val videoPlaying: MutableState + val progress: MutableState + val duration: MutableState + val preview: MutableState + + fun stop() + fun play(resetOnEnd: Boolean) + fun enableSound(enable: Boolean): Boolean + fun release(remove: Boolean) +} + +expect class VideoPlayer: VideoPlayerInterface { + companion object { + fun getOrCreate( + uri: URI, + gallery: Boolean, + defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean + ): VideoPlayer + fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean + fun release(uri: URI, gallery: Boolean, remove: Boolean) + fun stopAll() + fun releaseAll() + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Color.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt index 84686d7482..a01fc9de51 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.ui.theme +package chat.simplex.common.ui.theme import androidx.compose.ui.graphics.Color diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Shape.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Shape.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Shape.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Shape.kt index 79ab4eead4..293257c095 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Shape.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Shape.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.ui.theme +package chat.simplex.common.ui.theme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Shapes diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt similarity index 94% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Theme.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 0258ccbea0..3f106bd729 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -1,7 +1,5 @@ -package chat.simplex.app.ui.theme +package chat.simplex.common.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,13 +8,13 @@ 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.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.common.model.ChatController +import chat.simplex.common.platform.isInNightMode +import chat.simplex.common.views.helpers.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import chat.simplex.res.MR enum class DefaultTheme { SYSTEM, LIGHT, DARK, SIMPLEX; @@ -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 @@ -270,7 +264,7 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { } val systemDark = isSystemInDarkTheme() LaunchedEffect(systemDark) { - if (SimplexApp.context.chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) { + if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) { // Change active colors from light to dark and back based on system theme ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index 03d4af64c6..17ff695e73 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -1,18 +1,20 @@ -package chat.simplex.app.ui.theme +package chat.simplex.common.ui.theme import androidx.compose.material.Colors import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb -import chat.simplex.app.R -import chat.simplex.app.SimplexApp -import chat.simplex.app.model.AppPreferences -import chat.simplex.app.views.helpers.generalGetString +import androidx.compose.ui.text.font.FontFamily import chat.simplex.res.MR +import chat.simplex.common.model.AppPreferences +import chat.simplex.common.model.ChatController +import chat.simplex.common.views.helpers.generalGetString + +// https://github.com/rsms/inter +// I place it here because IDEA shows an error (but still works anyway) when this declaration inside Type.kt +expect val Inter: FontFamily object ThemeManager { - private val appPrefs: AppPreferences by lazy { - SimplexApp.context.chatModel.controller.appPrefs - } + private val appPrefs: AppPreferences = ChatController.appPrefs data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt similarity index 68% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt index a67a14d0c1..9acfffb3ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/ui/theme/Type.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Type.kt @@ -1,20 +1,9 @@ -package chat.simplex.app.ui.theme +package chat.simplex.common.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 -val Inter: FontFamily = FontFamily( - Font(MR.fonts.Inter.regular.fontResourceId), - Font(MR.fonts.Inter.italic.fontResourceId, style = FontStyle.Italic), - Font(MR.fonts.Inter.bold.fontResourceId, FontWeight.Bold), - Font(MR.fonts.Inter.semibold.fontResourceId, FontWeight.SemiBold), - 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( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/Preview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/Preview.kt new file mode 100644 index 0000000000..e83db99520 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/Preview.kt @@ -0,0 +1,7 @@ +package androidx.compose.desktop.ui.tooling.preview + +@Retention(AnnotationRetention.SOURCE) +@Target( + AnnotationTarget.FUNCTION +) +annotation class Preview diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/SplashView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt similarity index 94% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/SplashView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt index 9491ea75e0..cf9a8dfb62 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/SplashView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/SplashView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views +package chat.simplex.common.views import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt similarity index 86% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/TerminalView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 264c243ebd..b2692d541a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -1,7 +1,6 @@ -package chat.simplex.app.views +package chat.simplex.common.views -import android.content.res.Configuration -import androidx.activity.compose.BackHandler +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -9,19 +8,18 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily 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.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.* -import chat.simplex.app.views.helpers.* -import com.google.accompanist.insets.ProvideWindowInsets -import com.google.accompanist.insets.navigationBarsWithImePadding +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* @Composable fun TerminalView(chatModel: ChatModel, close: () -> Unit) { @@ -29,12 +27,12 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) { BackHandler(onBack = { close() }) - TerminalLayout( - remember { chatModel.terminalItems }, - composeState, - sendCommand = { sendCommand(chatModel, composeState) }, - close - ) + TerminalLayout( + remember { chatModel.terminalItems }, + composeState, + sendCommand = { sendCommand(chatModel, composeState) }, + close + ) } private fun sendCommand(chatModel: ChatModel, composeState: MutableState) { @@ -117,7 +115,7 @@ fun TerminalLog(terminalItems: List) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } } - val context = LocalContext.current + val clipboard = LocalClipboardManager.current LazyColumn(state = listState, reverseLayout = true) { items(reversedTerminalItems) { item -> Text( @@ -128,7 +126,7 @@ fun TerminalLog(terminalItems: List) { modifier = Modifier .fillMaxWidth() .clickable { - ModalManager.shared.showModal(endButtons = { ShareButton { shareText(item.details) } }) { + ModalManager.shared.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) { SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) { Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING)) } @@ -139,12 +137,11 @@ fun TerminalLog(terminalItems: List) { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewTerminalLayout() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/WelcomeView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index d273c17dbd..9539a07903 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views +package chat.simplex.common.views import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -22,13 +22,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.Profile -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.onboarding.OnboardingStage -import chat.simplex.app.views.onboarding.ReadableText -import com.google.accompanist.insets.navigationBarsWithImePadding +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.Profile +import chat.simplex.common.platform.navigationBarsWithImePadding +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt similarity index 82% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallManager.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index b87f996f8e..f6b3c899ae 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -1,10 +1,8 @@ -package chat.simplex.app.views.call +package chat.simplex.common.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 chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.withApi import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes @@ -16,10 +14,10 @@ class CallManager(val chatModel: ChatModel) { if (invitation.user.showNotifications) { if (Clock.System.now() - invitation.callTs <= 3.minutes) { activeCallInvitation.value = invitation - controller.ntfManager.notifyCallInvitation(invitation) + ntfManager.notifyCallInvitation(invitation) } else { val contact = invitation.contact - controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText) + ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText) } } } @@ -63,7 +61,7 @@ class CallManager(val chatModel: ChatModel) { callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null - controller.ntfManager.cancelCallNotification() + ntfManager.cancelCallNotification() } } } @@ -89,7 +87,7 @@ class CallManager(val chatModel: ChatModel) { callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null - controller.ntfManager.cancelCallNotification() + ntfManager.cancelCallNotification() } withApi { if (!controller.apiRejectCall(invitation.contact)) { @@ -102,7 +100,7 @@ class CallManager(val chatModel: ChatModel) { fun reportCallRemoteEnded(invitation: RcvCallInvitation) { if (chatModel.activeCallInvitation.value?.contact?.id == invitation.contact.id) { chatModel.activeCallInvitation.value = null - chatModel.controller.ntfManager.cancelCallNotification() + ntfManager.cancelCallNotification() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt new file mode 100644 index 0000000000..2f4ffbb836 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.views.call + +import androidx.compose.runtime.Composable + +@Composable +expect fun ActiveCallView() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt similarity index 85% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 532ecda9c6..447236286f 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -1,5 +1,6 @@ -package chat.simplex.app.views.call +package chat.simplex.common.views.call +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -10,34 +11,31 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalContext 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.SimplexApp -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.ProfileImage -import chat.simplex.app.views.usersettings.ProfilePreview +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.common.views.usersettings.ProfilePreview +import chat.simplex.common.platform.ntfManager +import chat.simplex.common.platform.SoundPlayer import chat.simplex.res.MR import kotlinx.datetime.Clock @Composable fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) { val cm = chatModel.callManager - val cxt = LocalContext.current val scope = rememberCoroutineScope() - LaunchedEffect(true) { SoundPlayer.shared.start(scope, sound = !chatModel.showCallView.value) } - DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } } + LaunchedEffect(true) { SoundPlayer.start(scope, sound = !chatModel.showCallView.value) } + DisposableEffect(true) { onDispose { SoundPlayer.stop() } } IncomingCallAlertLayout( invitation, chatModel, rejectCall = { cm.endCall(invitation = invitation) }, ignoreCall = { chatModel.activeCallInvitation.value = null - chatModel.controller.ntfManager.cancelCallNotification() + ntfManager.cancelCallNotification() }, acceptCall = { cm.acceptIncomingCall(invitation = invitation) } ) @@ -114,7 +112,7 @@ fun PreviewIncomingCallAlertLayout() { sharedKey = null, callTs = Clock.System.now() ), - chatModel = SimplexApp.context.chatModel, + chatModel = ChatModel, rejectCall = {}, ignoreCall = {}, acceptCall = {} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/WebRTC.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index cec29c94fe..3e14414fc7 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -1,11 +1,9 @@ -package chat.simplex.app.views.call +package chat.simplex.common.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.views.helpers.generalGetString +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.model.* import chat.simplex.res.MR import kotlinx.datetime.Instant import kotlinx.serialization.SerialName @@ -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/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 8e4fdb5337..ba15f426b0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat import InfoRow import InfoRowEllipsis @@ -8,8 +8,7 @@ import SectionItemView import SectionSpacer import SectionTextFooter import SectionView -import android.widget.Toast -import androidx.activity.compose.BackHandler +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.* @@ -26,16 +25,15 @@ 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.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.SimplexApp -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chatlist.updateChatSettings -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCode -import chat.simplex.app.views.usersettings.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -205,7 +203,7 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> if (r) { chatModel.removeChat(chatInfo.id) chatModel.chatId.value = null - chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id) + ntfManager.cancelNotificationsForChat(chatInfo.id) close?.invoke() } } @@ -224,7 +222,7 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit val updatedChatInfo = chatModel.controller.apiClearChat(chatInfo.chatType, chatInfo.apiId) if (updatedChatInfo != null) { chatModel.clearChat(updatedChatInfo) - chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id) + ntfManager.cancelNotificationsForChat(chatInfo.id) close?.invoke() } } @@ -296,7 +294,8 @@ fun ChatInfoLayout( if (contact.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { shareText(contact.contactLink) } + val clipboard = LocalClipboardManager.current + ShareAddressButton { clipboard.shareText(contact.contactLink) } SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) } SectionDividerSpaced() @@ -490,7 +489,7 @@ fun SimplexServers(text: String, servers: List) { val clipboardManager: ClipboardManager = LocalClipboardManager.current InfoRowEllipsis(text, info) { clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ","))) - Toast.makeText(SimplexApp.context, generalGetString(MR.strings.copied), Toast.LENGTH_SHORT).show() + showToast(generalGetString(MR.strings.copied)) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt similarity index 93% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatItemInfoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 4d7cc4b8ea..800c2e0a44 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat import InfoRow import SectionBottomSpacer @@ -12,19 +12,22 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString 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 androidx.compose.ui.unit.sp -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.CurrentColors -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.chat.item.ItemAction -import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.MarkdownText +import chat.simplex.common.views.helpers.* +import chat.simplex.common.platform.shareText import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -88,13 +91,14 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { } } if (text != "") { + val clipboard = LocalClipboardManager.current DefaultDropdownMenu(showMenu) { ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - shareText(text) + clipboard.shareText(text) showMenu.value = false }) ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyText(text) + clipboard.setText(AnnotatedString(text)) showMenu.value = false }) } @@ -126,13 +130,14 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { ) } if (text != "") { + val clipboard = LocalClipboardManager.current DefaultDropdownMenu(showMenu) { ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - shareText(text) + clipboard.shareText(text) showMenu.value = false }) ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyText(text) + clipboard.setText(AnnotatedString(text)) showMenu.value = false }) } @@ -153,12 +158,10 @@ fun ChatItemInfoView(ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { if (itemDeleted.deletedTs != null) { InfoRow(stringResource(MR.strings.info_row_deleted_at), localTimestamp(itemDeleted.deletedTs)) } - is CIDeleted.Moderated -> if (itemDeleted.deletedTs != null) { InfoRow(stringResource(MR.strings.info_row_moderated_at), localTimestamp(itemDeleted.deletedTs)) } - else -> {} } val deleteAt = ci.meta.itemTimed?.deleteAt @@ -286,12 +289,10 @@ fun itemInfoShareText(ci: ChatItem, chatItemInfo: ChatItemInfo, devTools: Boolea if (itemDeleted.deletedTs != null) { shareText.add(String.format(generalGetString(MR.strings.share_text_deleted_at), localTimestamp(itemDeleted.deletedTs))) } - is CIDeleted.Moderated -> if (itemDeleted.deletedTs != null) { shareText.add(String.format(generalGetString(MR.strings.share_text_moderated_at), localTimestamp(itemDeleted.deletedTs))) } - else -> {} } val deleteAt = ci.meta.itemTimed?.deleteAt diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index ca64f5c8d4..3478f33ef2 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1,10 +1,6 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat -import android.content.res.Configuration -import android.graphics.Bitmap -import android.net.Uri -import android.util.Log -import androidx.activity.compose.BackHandler +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* import androidx.compose.foundation.layout.* @@ -26,25 +22,24 @@ import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.* -import androidx.core.content.FileProvider -import chat.simplex.app.* -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.call.* -import chat.simplex.app.views.chat.group.* -import chat.simplex.app.views.chat.item.* -import chat.simplex.app.views.chatlist.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.helpers.AppBarHeight -import com.google.accompanist.insets.ProvideWindowInsets -import com.google.accompanist.insets.navigationBarsWithImePadding +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.group.* +import chat.simplex.common.views.chat.item.* +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.GroupInfo +import chat.simplex.common.platform.* +import chat.simplex.common.platform.AudioPlayer import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock import java.io.File +import java.net.URI import kotlin.math.sign @Composable @@ -100,8 +95,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { .collect { activeChat.value = it } } } - val view = LocalView.current - val context = LocalContext.current + val view = LocalMultiplatformView() if (activeChat.value == null || user == null) { chatModel.chatId.value = null } else { @@ -113,6 +107,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0 } } + val clipboard = LocalClipboardManager.current ChatLayout( chat, @@ -320,7 +315,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { val ciInfo = chatModel.controller.apiGetChatItemInfo(cInfo.chatType, cInfo.apiId, cItem.id) if (ciInfo != null) { ModalManager.shared.showModal(endButtons = { ShareButton { - shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) + clipboard.shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) } }) { ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) } @@ -338,7 +333,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { }, markRead = { range, unreadCountAfter -> chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) - chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) + ntfManager.cancelNotificationsForChat(chat.id) withBGApi { chatModel.controller.apiChatRead( chat.chatInfo.chatType, @@ -471,7 +466,7 @@ fun ChatInfoToolbar( val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>() menuItems.add { - ItemAction(stringResource(MR.strings.search_verb).capitalize(Locale.current), painterResource(MR.images.ic_search), onClick = { + ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false showSearch = true }) @@ -657,8 +652,8 @@ fun BoxWithConstraintsScope.ChatItemsList( .distinctUntilChanged() .filter { !stopListening } .collect { - onComposed() - stopListening = true + onComposed() + stopListening = true } } DisposableEffectOnGone( @@ -1016,8 +1011,8 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha } sealed class ProviderMedia { - data class Image(val uri: Uri, val image: Bitmap): ProviderMedia() - data class Video(val uri: Uri, val preview: String): ProviderMedia() + data class Image(val uri: URI, val image: ImageBitmap): ProviderMedia() + data class Video(val uri: URI, val preview: String): ProviderMedia() } private fun providerForGallery( @@ -1054,17 +1049,17 @@ private fun providerForGallery( val item = item(internalIndex, initialChatId)?.second ?: return null return when (item.content.msgContent) { is MsgContent.MCImage -> { - val imageBitmap: Bitmap? = getLoadedImage(item.file) + val imageBitmap: ImageBitmap? = getLoadedImage(item.file) val filePath = getLoadedFilePath(item.file) if (imageBitmap != null && filePath != null) { - val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) ProviderMedia.Image(uri, imageBitmap) } else null } is MsgContent.MCVideo -> { val filePath = getLoadedFilePath(item.file) if (filePath != null) { - val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image) } else null } @@ -1108,12 +1103,11 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf override val touchSlop: Float get() = slop } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatLayout() { SimpleXTheme { @@ -1181,7 +1175,7 @@ fun PreviewChatLayout() { } } -@Preview(showBackground = true) +@Preview @Composable fun PreviewGroupChatLayout() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt index 8043b7c8e8..83076f885b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt @@ -1,3 +1,6 @@ +package chat.simplex.common.views.chat + +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -7,10 +10,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.ui.theme.* +import chat.simplex.common.ui.theme.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt similarity index 85% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt index 3c36a6e2af..906065f741 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -10,15 +10,13 @@ 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 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.ui.theme.* -import chat.simplex.app.views.helpers.UploadContent -import chat.simplex.app.views.helpers.base64ToBitmap +import chat.simplex.common.platform.base64ToBitmap import chat.simplex.res.MR +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.UploadContent @Composable fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) { @@ -38,7 +36,7 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni val content = media.content[index] if (content is UploadContent.Video) { Box(contentAlignment = Alignment.Center) { - val imageBitmap = base64ToBitmap(item).asImageBitmap() + val imageBitmap = base64ToBitmap(item) Image( imageBitmap, "preview video", @@ -53,7 +51,7 @@ fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Uni ) } } else { - val imageBitmap = base64ToBitmap(item).asImageBitmap() + val imageBitmap = base64ToBitmap(item) Image( imageBitmap, "preview image", diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt similarity index 78% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index f0569c6661..6912ced0e8 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -1,22 +1,6 @@ @file:UseSerializers(UriSerializer::class) -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat -import ComposeFileView -import ComposeVoiceView -import android.Manifest -import android.app.Activity -import android.content.* -import android.content.pm.PackageManager -import android.graphics.* -import android.graphics.drawable.AnimatedImageDrawable -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.* @@ -26,21 +10,20 @@ 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 androidx.compose.ui.graphics.ImageBitmap 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.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.item.* +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.* import java.io.File +import java.net.URI import java.nio.file.Files @Serializable @@ -49,7 +32,7 @@ sealed class 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 class FilePreview(val fileName: String, val uri: URI): ComposePreview() } @Serializable @@ -164,6 +147,14 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { } } +@Composable +expect fun AttachmentSelection( + composeState: MutableState, + attachmentOption: MutableState, + processPickedFile: (URI?, String?) -> Unit, + processPickedMedia: (List, String?) -> Unit +) + @Composable fun ComposeView( chatModel: ChatModel, @@ -172,7 +163,6 @@ fun ComposeView( attachmentOption: MutableState, showChooseAttachment: () -> Unit ) { - val context = LocalContext.current val linkUrl = rememberSaveable { mutableStateOf(null) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } @@ -181,37 +171,17 @@ fun ComposeView( 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) - if (bitmap != null) { - val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) - composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri)))) - } - } - } - val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> - 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 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 + var bitmap: ImageBitmap? when { - isImage -> { + isImage(uri) -> { // 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) { + bitmap = getBitmapFromUri(uri) + if (isAnimImage(uri, drawable)) { // It's a gif or webp val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { @@ -243,7 +213,7 @@ fun ComposeView( composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content)) } } - val processPickedFile = { uri: Uri?, text: String? -> + val processPickedFile = { uri: URI?, text: String? -> if (uri != null) { val fileSize = getFileSize(uri) if (fileSize != null && fileSize <= maxFileSize) { @@ -259,50 +229,9 @@ fun ComposeView( } } } - val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) } - val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) } - val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it, null) } - 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) -> { - cameraLauncher.launchWithFallback() - } - else -> { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - attachmentOption.value = null - } - AttachmentOption.GalleryImage -> { - try { - galleryImageLauncher.launch(0) - } catch (e: ActivityNotFoundException) { - galleryImageLauncherFallback.launch("image/*") - } - attachmentOption.value = null - } - AttachmentOption.GalleryVideo -> { - try { - galleryVideoLauncher.launch(0) - } catch (e: ActivityNotFoundException) { - galleryVideoLauncherFallback.launch("video/*") - } - attachmentOption.value = null - } - AttachmentOption.File -> { - filesLauncher.launch("*/*") - attachmentOption.value = null - } - else -> {} - } - } + AttachmentSelection(composeState, attachmentOption, processPickedFile, processPickedMedia) fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) @@ -478,7 +407,7 @@ fun ComposeView( is ComposePreview.VoicePreview -> { val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) - val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderNative.extension, ""))) + val actualFile = File(getAppFilePath(tmpFile.name.replaceAfter(RecorderInterface.extension, ""))) withContext(Dispatchers.IO) { Files.move(tmpFile.toPath(), actualFile.toPath()) } @@ -510,7 +439,7 @@ fun ComposeView( (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) } } @@ -568,7 +497,7 @@ fun ComposeView( recState.value = RecordingState.NotStarted composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) withBGApi { - RecorderNative.stopRecording?.invoke() + RecorderInterface.stopRecording?.invoke() AudioPlayer.stop(filePath) filePath?.let { File(it).delete() } } @@ -775,32 +704,26 @@ fun ComposeView( } } - 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() + DisposableEffectOnGone { + 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) } @@ -833,65 +756,3 @@ fun ComposeView( } } } - -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/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index 6ea0b345f8..99d7de96be 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -1,4 +1,7 @@ +package chat.simplex.common.views.chat + import androidx.compose.animation.core.Animatable +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -12,13 +15,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity 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 androidx.compose.ui.unit.sp -import chat.simplex.app.R -import chat.simplex.app.model.durationText -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.durationText +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.platform.AudioPlayer import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 046571e008..465603d409 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat import InfoRow import SectionBottomSpacer @@ -15,11 +15,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.PreferenceToggle +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.common.model.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index a92db078c3..9a4d4c3775 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -10,12 +10,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.item.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.* +import chat.simplex.common.model.* import chat.simplex.res.MR import kotlinx.datetime.Clock diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt similarity index 54% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 69304f8398..91fb4a6e8b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -1,30 +1,20 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.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.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource @Composable -fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - ScanCodeLayout(verifyCode, close) -} +expect fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) @Composable -private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { +fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { Column( Modifier .fillMaxSize() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt similarity index 73% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 4c2bf1ca27..df76617a23 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -1,20 +1,7 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.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.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -28,32 +15,19 @@ 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 com.google.accompanist.permissions.rememberMultiplePermissionsState +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatItem +import chat.simplex.common.platform.* 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 dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* -import java.lang.reflect.Field @Composable fun SendMsgView( @@ -92,7 +66,7 @@ fun SendMsgView( 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) + PlatformTextField(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange) // Disable clicks on text field if (cs.preview is ComposePreview.VoicePreview || !userCanSend || cs.inProgress) { Box( @@ -112,7 +86,6 @@ fun SendMsgView( 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 @@ -135,8 +108,8 @@ fun SendMsgView( } } } - !permissionsState.allPermissionsGranted -> - VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() } + !allowedToRecordVoiceByPlatform() -> + VoiceButtonWithoutPermissionByPlatform() else -> RecordVoiceView(recState, stopRecOnNextClick) } @@ -220,6 +193,12 @@ fun SendMsgView( } } +@Composable +expect fun allowedToRecordVoiceByPlatform(): Boolean + +@Composable +expect fun VoiceButtonWithoutPermissionByPlatform() + @Composable private fun CustomDisappearingMessageDialog( sendMessage: (Int?) -> Unit, @@ -258,7 +237,7 @@ private fun CustomDisappearingMessageDialog( } } - Dialog(onDismissRequest = { setShowDialog(false) }) { + DefaultDialog(onDismissRequest = { setShowDialog(false) }) { Surface( shape = RoundedCornerShape(corner = CornerSize(25.dp)) ) { @@ -290,7 +269,6 @@ private fun CustomDisappearingMessageDialog( .clickable { setShowDialog(false) } ) } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) { sendMessage(30) setShowDialog(false) @@ -313,124 +291,6 @@ private fun CustomDisappearingMessageDialog( } } -@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( @@ -443,7 +303,7 @@ private fun BoxScope.DeleteTextButton(composeState: MutableState) @Composable private fun RecordVoiceView(recState: MutableState, stopRecOnNextClick: MutableState) { - val rec: Recorder = remember { RecorderNative() } + val rec: RecorderInterface = remember { RecorderNative() } DisposableEffect(Unit) { onDispose { rec.stop() } } val stopRecordingAndAddAudio: () -> Unit = { recState.value.filePathNullable?.let { @@ -505,7 +365,7 @@ private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) { } @Composable -private fun VoiceButtonWithoutPermission(onClick: () -> Unit) { +fun VoiceButtonWithoutPermission(onClick: () -> Unit) { IconButton(onClick, Modifier.size(36.dp)) { Icon( painterResource(MR.images.ic_keyboard_voice_filled), @@ -518,24 +378,6 @@ private fun VoiceButtonWithoutPermission(onClick: () -> Unit) { } } -@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)) { @@ -721,12 +563,11 @@ private fun showDisabledVoiceAlert(isDirectChat: Boolean) { ) } -@Preview(showBackground = true) -@Preview( +@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) @@ -751,12 +592,11 @@ fun PreviewSendMsgView() { } } -@Preview(showBackground = true) -@Preview( +@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) @@ -782,12 +622,11 @@ fun PreviewSendMsgViewEditing() { } } -@Preview(showBackground = true) -@Preview( +@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) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt similarity index 84% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index 9a122f360b..bb79fce66c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.common.views.chat import SectionBottomSpacer import SectionView @@ -10,15 +10,17 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager import dev.icerock.moko.resources.compose.painterResource 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.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCode +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.shareText +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode import chat.simplex.res.MR @Composable @@ -87,7 +89,8 @@ private fun VerifyCodeLayout( ) } Box(Modifier.weight(1f)) { - IconButton({ shareText(connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) { + val clipboard = LocalClipboardManager.current + IconButton({ clipboard.shareText(connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) { Icon(painterResource(MR.images.ic_share_filled), null, tint = MaterialTheme.colors.primary) } } @@ -108,9 +111,11 @@ private fun VerifyCodeLayout( verifyCode(null) {} } } else { - SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) { - ModalManager.shared.showModal { - ScanCodeView(verifyCode) { } + if (appPlatform.isAndroid) { + SimpleButton(generalGetString(MR.strings.scan_code), painterResource(MR.images.ic_qr_code)) { + ModalManager.shared.showModal { + ScanCodeView(verifyCode) { } + } } } SimpleButton(generalGetString(MR.strings.mark_code_verified), painterResource(MR.images.ic_verified_user)) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index d061f15885..05dbab28a7 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.group +package chat.simplex.common.views.chat.group import SectionBottomSpacer import SectionCustomFooter @@ -6,7 +6,6 @@ import SectionDividerSpaced import SectionItemView import SectionSpacer import SectionView -import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -16,20 +15,22 @@ 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.LocalView import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.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.ui.theme.* -import chat.simplex.app.views.chat.ChatInfoToolbarTitle -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.InfoAboutIncognito -import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.ChatInfoToolbarTitle +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.InfoAboutIncognito +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.common.model.GroupInfo +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.group.GroupPreferencesView import chat.simplex.res.MR @Composable @@ -184,7 +185,7 @@ private fun SearchRowView( SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) { searchText.value = searchText.value.copy(it) } - val view = LocalView.current + val view = LocalMultiplatformView() LaunchedEffect(selectedContactsSize) { searchText.value = searchText.value.copy("") hideKeyboard(view) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index a368e2350f..77cfe964c0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.group +package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer @@ -7,8 +7,7 @@ import SectionItemView import SectionSpacer import SectionTextFooter import SectionView -import android.util.Log -import androidx.activity.compose.BackHandler +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -23,17 +22,18 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue 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 androidx.compose.ui.unit.sp -import chat.simplex.app.TAG -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.* -import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert -import chat.simplex.app.views.chatlist.setGroupMembers -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.cantInviteIncognitoAlert +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.model.GroupInfo +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.ClearChatButton +import chat.simplex.common.views.chat.clearChatDialog import chat.simplex.res.MR @Composable @@ -122,7 +122,7 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM if (r) { chatModel.removeChat(chatInfo.id) chatModel.chatId.value = null - chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id) + ntfManager.cancelNotificationsForChat(chatInfo.id) close?.invoke() } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 025251cd8b..22b9c8a93a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.group +package chat.simplex.common.views.chat.group import SectionBottomSpacer import androidx.compose.foundation.layout.* @@ -10,15 +10,16 @@ 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.platform.LocalClipboardManager 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.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCode +import chat.simplex.common.model.* +import chat.simplex.common.platform.shareText +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode import chat.simplex.res.MR @Composable @@ -43,13 +44,14 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St createLink() } } + val clipboard = LocalClipboardManager.current GroupLinkLayout( groupLink = groupLink, groupInfo, groupLinkMemberRole, creatingLink, createLink = ::createLink, - share = { shareText(groupLink ?: return@GroupLinkLayout) }, + share = { clipboard.shareText(groupLink ?: return@GroupLinkLayout) }, updateLink = { val role = groupLinkMemberRole.value if (role != null) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index df36cf9aff..b712a629c1 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.group +package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer @@ -6,9 +6,8 @@ import SectionDividerSpaced import SectionSpacer import SectionTextFooter import SectionView -import android.net.Uri -import android.util.Log -import androidx.activity.compose.BackHandler +import androidx.compose.desktop.ui.tooling.preview.Preview +import java.net.URI import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.InlineTextContent @@ -18,23 +17,23 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.* 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.TextAlign 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.* -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.* -import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.* +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.common.model.GroupInfo +import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -77,7 +76,7 @@ fun GroupMemberInfoView( } }, connectViaAddress = { connReqUri -> - val uri = Uri.parse(connReqUri) + val uri = URI(connReqUri) withUriAction(uri) { linkType -> withApi { Log.d(TAG, "connectViaUri: connecting") @@ -262,10 +261,10 @@ fun GroupMemberInfoLayout( } if (member.contactLink != null) { - val context = LocalContext.current SectionView(stringResource(MR.strings.address_section_title).uppercase()) { QRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { shareText(member.contactLink) } + val clipboard = LocalClipboardManager.current + ShareAddressButton { clipboard.shareText(member.contactLink) } if (contactId != null) { if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 1801a0ff75..d95d2a4cff 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.group +package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer @@ -14,11 +14,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon +import chat.simplex.common.model.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt similarity index 90% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 5031babd49..ac13dd483a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -1,8 +1,7 @@ -package chat.simplex.app.views.chat.group +package chat.simplex.common.views.chat.group import SectionBottomSpacer -import android.content.res.Configuration -import android.net.Uri +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,22 +14,20 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color 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 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.ProfileNameField -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.isValidDisplayName -import chat.simplex.app.views.onboarding.ReadableText -import chat.simplex.app.views.usersettings.* -import com.google.accompanist.insets.ProvideWindowInsets -import com.google.accompanist.insets.navigationBarsWithImePadding +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.ProfileNameField +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.isValidDisplayName +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.net.URI @Composable fun GroupProfileView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { @@ -58,7 +55,7 @@ fun GroupProfileLayout( val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) } val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) } - val chosenImage = rememberSaveable { mutableStateOf(null) } + val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) } val scope = rememberCoroutineScope() val scrollState = rememberScrollState() @@ -191,12 +188,11 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { ) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewGroupProfileLayout() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 7cd7c9ca38..3be54376d5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.group +package chat.simplex.common.views.chat.group import SectionBottomSpacer import SectionDividerSpaced @@ -14,16 +14,18 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString 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.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.chat.item.MarkdownText +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.GroupInfo import chat.simplex.res.MR import kotlinx.coroutines.delay @@ -99,15 +101,17 @@ private fun GroupWelcomeLayout( }, wt.value.isEmpty() ) - CopyTextButton { copyText(wt.value) } + val clipboard = LocalClipboardManager.current + CopyTextButton { clipboard.setText(AnnotatedString(wt.value)) } SectionDividerSpaced(maxBottomPadding = false) SaveButton( save = save, disabled = wt.value == groupInfo.groupProfile.description || (wt.value == "" && groupInfo.groupProfile.description == null) ) } else { + val clipboard = LocalClipboardManager.current TextPreview(wt.value, linkMode) - CopyTextButton { copyText(wt.value) } + CopyTextButton { clipboard.setText(AnnotatedString(wt.value)) } } SectionBottomSpacer() } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index b2f2245d23..3e4dce7f4a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -10,9 +10,9 @@ 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.ui.theme.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.model.* +import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt similarity index 88% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index 752cf8681b..e919ab1aa8 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -9,7 +9,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.* +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.Feature @Composable fun CIChatFeatureView( diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt similarity index 85% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt index 32933a57d7..09d0bd4d04 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIEventView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt @@ -1,6 +1,6 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item -import android.content.res.Configuration +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.* @@ -9,11 +9,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.compose.ui.unit.sp -import chat.simplex.app.model.ChatItem -import chat.simplex.app.ui.theme.* +import chat.simplex.common.model.ChatItem +import chat.simplex.common.ui.theme.* @Composable fun CIEventView(ci: ChatItem) { @@ -46,11 +45,10 @@ fun chatEventText(ci: ChatItem): AnnotatedString = withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" -) +)*/ @Composable fun CIEventViewPreview() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt similarity index 93% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt index cdb261d58b..1a23dfc49a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFeaturePreferenceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -9,9 +9,8 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight 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.views.helpers.generalGetString +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.model.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 7c9e49766b..6207c1648e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item -import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -12,19 +11,18 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import dev.icerock.moko.resources.compose.painterResource 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.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR -import kotlinx.datetime.Clock +import java.io.File +import java.net.URI @Composable fun CIFileView( @@ -32,7 +30,6 @@ fun CIFileView( edited: Boolean, receiveFile: (Long) -> Unit ) { - val context = LocalContext.current val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) @Composable @@ -98,9 +95,11 @@ fun CIFileView( is CIFileStatus.RcvComplete -> { val filePath = getLoadedFilePath(file) if (filePath != null) { - saveFileLauncher.launch(file.fileName) + withApi { + saveFileLauncher.launch(file.fileName) + } } else { - Toast.makeText(context, generalGetString(MR.strings.file_not_found), Toast.LENGTH_SHORT).show() + showToast(generalGetString(MR.strings.file_not_found)) } } else -> {} @@ -206,6 +205,16 @@ fun CIFileView( } } +@Composable +fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = + rememberFileChooserLauncher(false) { to: URI? -> + val filePath = getLoadedFilePath(ciFile) + if (filePath != null && to != null) { + copyFileToFile(File(filePath), to) {} + } + } + +/* class ChatItemProvider: PreviewParameterProvider { private val sentFile = ChatItem( chatDir = CIDirection.DirectSnd(), @@ -244,4 +253,4 @@ fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatI SimpleXTheme { FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {}) } -} +}*/ diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt similarity index 93% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index d66dc4ae23..6ee29b8304 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -1,6 +1,6 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item -import android.content.res.Configuration +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -11,13 +11,11 @@ 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.TextOverflow -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.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* import chat.simplex.res.MR @Composable @@ -115,11 +113,10 @@ fun CIGroupInvitationView( } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" -) +)*/ @Composable fun PendingCIGroupInvitationViewPreview() { SimpleXTheme { @@ -132,11 +129,10 @@ fun PendingCIGroupInvitationViewPreview() { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" -) +)*/ @Composable fun CIGroupInvitationViewAcceptedPreview() { SimpleXTheme { @@ -149,7 +145,7 @@ fun CIGroupInvitationViewAcceptedPreview() { } } -@Preview(showBackground = true) +@Preview @Composable fun CIGroupInvitationViewLongNamePreview() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt similarity index 75% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 642757f271..db772d9dc6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -1,7 +1,5 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.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.* @@ -10,9 +8,7 @@ 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.BitmapPainter +import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layoutId @@ -21,19 +17,13 @@ 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.views.helpers.* -import coil.ImageLoader -import coil.compose.rememberAsyncImagePainter -import coil.decode.GifDecoder -import coil.decode.ImageDecoderDecoder -import coil.request.ImageRequest +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import java.io.File +import java.net.URI @Composable fun CIImageView( @@ -95,13 +85,13 @@ fun CIImageView( @Composable fun imageViewFullWidth(): Dp { val approximatePadding = 100.dp - return with(LocalDensity.current) { minOf(1000.dp, LocalView.current.width.toDp() - approximatePadding) } + return with(LocalDensity.current) { minOf(1000.dp, LocalWindowWidth() - approximatePadding) } } @Composable - fun imageView(imageBitmap: Bitmap, onClick: () -> Unit) { + fun imageView(imageBitmap: ImageBitmap, onClick: () -> Unit) { Image( - imageBitmap.asImageBitmap(), + imageBitmap, 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 @@ -116,7 +106,7 @@ fun CIImageView( } @Composable - fun imageView(painter: Painter, onClick: () -> Unit) { + fun ImageView(painter: Painter, onClick: () -> Unit) { Image( painter, contentDescription = stringResource(MR.strings.image_descr), @@ -139,8 +129,8 @@ fun CIImageView( return false } - fun imageAndFilePath(file: CIFile?): Pair { - val imageBitmap: Bitmap? = getLoadedImage(file) + fun imageAndFilePath(file: CIFile?): Pair { + val imageBitmap: ImageBitmap? = getLoadedImage(file) val filePath = getLoadedFilePath(file) return imageBitmap to filePath } @@ -149,24 +139,10 @@ fun CIImageView( 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) - } - } - }) + val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } + SimpleAndAnimatedImageView(uri, imageBitmap, file, imageProvider, @Composable { painter, onClick -> ImageView(painter, onClick) }) } else { imageView(base64ToBitmap(image), onClick = { if (file != null) { @@ -205,12 +181,11 @@ fun CIImageView( } } -private val imageLoader = ImageLoader.Builder(SimplexApp.context) - .components { - if (SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .build() +@Composable +expect fun SimpleAndAnimatedImageView( + uri: URI, + imageBitmap: ImageBitmap, + file: CIFile?, + imageProvider: () -> ImageGalleryProvider, + ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit +) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt similarity index 74% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt index 64111ee4ea..92baa6a971 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIInvalidJSONView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import SectionView import androidx.compose.foundation.* @@ -7,14 +7,15 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager 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.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.common.platform.shareText +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR @Composable @@ -32,8 +33,9 @@ fun InvalidJSONView(json: String) { Column { Spacer(Modifier.height(DEFAULT_PADDING)) SectionView { + val clipboard = LocalClipboardManager.current SettingsActionItem(painterResource(MR.images.ic_share), generalGetString(MR.strings.share_verb), click = { - shareText(json) + clipboard.shareText(json) }) } Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt similarity index 93% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index 592f8e6b6d..86cd0acbfe 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -9,10 +9,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.* -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.CurrentColors +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.model.* import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -162,7 +163,7 @@ fun PreviewCIMetaViewEditedUnread() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = true, - status=CIStatus.RcvNew() + status= CIStatus.RcvNew() ), null ) @@ -175,7 +176,7 @@ fun PreviewCIMetaViewEditedSent() { chatItem = ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = true, - status=CIStatus.SndSent() + status= CIStatus.SndSent() ), null ) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIRcvDecryptionError.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index 060c94028e..2ce52f9712 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -10,14 +10,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle -import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.CurrentColors -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource @Composable fun CIRcvDecryptionError( diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt similarity index 84% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index f6123e7bed..650c525695 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -1,8 +1,5 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.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 @@ -11,27 +8,21 @@ 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.* 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 chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* import dev.icerock.moko.resources.StringResource import java.io.File +import java.net.URI @Composable fun CIVideoView( @@ -46,12 +37,11 @@ fun CIVideoView( 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 + val uri = remember(filePath) { getAppFileUri(filePath.substringAfterLast(File.separator)) } + val view = LocalMultiplatformView() VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { hideKeyboard(view) ModalManager.shared.showCustomModal(animated = false) { close -> @@ -99,14 +89,13 @@ fun CIVideoView( } @Composable -private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState, onClick: () -> Unit) { - val context = LocalContext.current +private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState, onClick: () -> Unit) { 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 soundEnabled by rememberSaveable(uri.path) { player.soundEnabled } val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo } val play = { player.enableSound(true) @@ -125,20 +114,12 @@ private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDur 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() } - ) + PlayerView( + player, + width, + onClick = onClick, + onLongClick = { showMenu.value = true }, + stop ) if (showPreview.value) { ImageView(preview, showMenu, onClick) @@ -148,6 +129,9 @@ private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDur } } +@Composable +expect fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) + @Composable private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) { Surface( @@ -216,11 +200,11 @@ private fun DurationProgress(file: CIFile, playing: MutableState, durat } @Composable -private fun ImageView(preview: Bitmap, showMenu: MutableState, onClick: () -> Unit) { +private fun ImageView(preview: ImageBitmap, 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(), + preview, contentDescription = stringResource(MR.strings.video_descr), modifier = Modifier .width(width) @@ -233,15 +217,7 @@ private fun ImageView(preview: Bitmap, showMenu: MutableState, onClick: } @Composable -private fun LocalWindowWidth(): Dp { - val view = LocalView.current - val density = LocalDensity.current.density - return remember { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - (rect.width() / density).dp - } -} +expect fun LocalWindowWidth(): Dp @Composable private fun progressIndicator() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 8d331c2276..cb557b0516 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* @@ -15,12 +15,14 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* 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.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource +import androidx.compose.ui.unit.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.getLoadedFilePath +import chat.simplex.common.platform.AudioPlayer +import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901 @@ -42,7 +44,6 @@ fun CIVoiceView( verticalAlignment = Alignment.CenterVertically ) { if (file != null) { - val context = LocalContext.current val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(file) } var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) } val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) } @@ -107,7 +108,7 @@ private fun VoiceLayout( MaterialTheme.colors.primary.mixWith( backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha), 0.24f) - val width = with(LocalDensity.current) { LocalView.current.width.toDp() } + val width = LocalWindowWidth() val colors = SliderDefaults.colors( inactiveTrackColor = inactiveTrackColor ) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 4829e05b2e..2a44d7d8ec 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -1,7 +1,6 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item -import android.Manifest -import android.os.Build +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,17 +13,18 @@ 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.ui.text.AnnotatedString 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.ui.theme.* -import chat.simplex.app.views.chat.* -import chat.simplex.app.views.helpers.* -import com.google.accompanist.permissions.rememberPermissionState +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.chat.ComposeContextItem +import chat.simplex.common.views.chat.ComposeState import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -55,14 +55,12 @@ fun ChatItemView( 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) } - val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) val onLinkLongClick = { _: String -> showMenu.value = true } val live = composeState.value.liveMessage != null @@ -164,6 +162,7 @@ fun ChatItemView( @Composable fun MsgContentItemDropdownMenu() { + val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) DefaultDropdownMenu(showMenu) { if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() @@ -178,37 +177,21 @@ fun ChatItemView( showMenu.value = false }) } + val clipboard = LocalClipboardManager.current 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) + else -> clipboard.shareText(cItem.content.text) } showMenu.value = false }) ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyText(cItem.content.text) + clipboard.setText(AnnotatedString(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.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice && getLoadedFilePath(cItem.file) != null) { + SaveContentItemAction(cItem, saveFileLauncher, 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 = { @@ -340,6 +323,9 @@ fun ChatItemView( } } +@Composable +expect fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) + @Composable fun CancelFileItemAction( fileId: Long, diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt similarity index 86% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt index 49a9ce28e6..3d8d9f7940 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item -import android.content.res.Configuration import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -10,11 +9,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.ChatItem -import chat.simplex.app.ui.theme.* +import chat.simplex.common.model.ChatItem +import chat.simplex.common.ui.theme.* @Composable fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) { @@ -42,11 +41,10 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" -) +)*/ @Composable fun PreviewDeletedItemView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 86fffc6ae0..d99de4019d 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.ChatItem +import chat.simplex.common.model.ChatItem val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp) val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt similarity index 98% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 6dc7920235..18a14efe34 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -19,12 +19,12 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow -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.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.base64ToBitmap import chat.simplex.res.MR import kotlinx.datetime.Clock import kotlin.math.min @@ -116,7 +116,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() + val imageBitmap = base64ToBitmap(qi.content.image) Image( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), @@ -128,7 +128,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() + val imageBitmap = base64ToBitmap(qi.content.image) Image( imageBitmap, contentDescription = stringResource(MR.strings.video_descr), @@ -283,8 +283,8 @@ fun PriorityLayout( content: @Composable () -> Unit ) { /** - * Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints] - * */ + * Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints] + * */ fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31 width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height @@ -317,6 +317,7 @@ fun PriorityLayout( } } } +/* class EditedProvider: PreviewParameterProvider { override val values = listOf(false, true).asSequence() @@ -506,3 +507,4 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi ) } } +*/ diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt similarity index 61% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 9cfaa3481f..270c671fc1 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -1,43 +1,23 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.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.material.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState 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.unit.* -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 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 chat.simplex.common.platform.* +import chat.simplex.common.views.chat.ProviderMedia +import chat.simplex.common.views.helpers.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import java.net.URI import kotlin.math.absoluteValue interface ImageGalleryProvider { @@ -49,7 +29,6 @@ interface ImageGalleryProvider { fun onDismiss(index: Int) } -@OptIn(ExperimentalPagerApi::class) @Composable fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { val provider = remember { imageProvider() } @@ -65,11 +44,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } } val scope = rememberCoroutineScope() - val playersToRelease = rememberSaveable { mutableSetOf() } + val playersToRelease = rememberSaveable { mutableSetOf() } DisposableEffectOnGone( whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } } ) - HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index -> + HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Column( Modifier .fillMaxSize() @@ -141,29 +120,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> ) } .fillMaxSize() + // LALAL + // https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24 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, - ) + val (uri: URI, imageBitmap: ImageBitmap) = 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) @@ -177,8 +138,10 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } @Composable -private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) { - val context = LocalContext.current +expect fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) + +@Composable +private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) { val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) } val isCurrentPage = rememberUpdatedState(currentPage) val play = { @@ -195,25 +158,9 @@ private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, curr } 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 - } - }, - modifier - ) + FullScreenVideoView(player, modifier) } } + +@Composable +expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt similarity index 86% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 77a0219ca4..7cd996ec25 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item -import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -13,16 +12,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.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.ChatItem -import chat.simplex.app.model.MsgErrorType -import chat.simplex.app.ui.theme.CurrentColors -import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.MsgErrorType +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR @Composable @@ -76,11 +74,10 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" -) +)*/ @Composable fun IntegrityErrorItemViewPreview() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt similarity index 85% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 22192a3692..8ced77289c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -1,6 +1,4 @@ -package chat.simplex.app.views.chat.item - -import android.content.res.Configuration +package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -11,14 +9,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.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.CIDeleted -import chat.simplex.app.model.ChatItem -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.common.model.CIDeleted +import chat.simplex.common.model.ChatItem +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -60,11 +57,10 @@ private fun MarkedDeletedText(text: String) { ) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" -) +)*/ @Composable fun PreviewMarkedDeletedItemView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index de5fd1594e..07c5db4d71 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -1,9 +1,5 @@ -package chat.simplex.app.views.chat.item +package chat.simplex.common.views.chat.item -import android.app.Activity -import android.content.ActivityNotFoundException -import android.util.Log -import androidx.annotation.IntRange import androidx.compose.foundation.text.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -19,11 +15,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import androidx.core.text.BidiFormatter -import chat.simplex.app.TAG -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.CurrentColors -import chat.simplex.app.views.helpers.detectGesture +import chat.simplex.common.model.* +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.helpers.DisposableEffectOnGone +import chat.simplex.common.views.helpers.detectGesture import kotlinx.coroutines.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) @@ -56,7 +53,7 @@ private val typingIndicators: List = listOf( ) -private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString { +private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = buildAnnotatedString { pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp)) append(if (recent) typingIndicators[typingIdx] else noTyping) } @@ -82,7 +79,7 @@ fun MarkdownText ( onLinkLongClick: (link: String) -> Unit = {} ) { val textLayoutDirection = remember (text) { - if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr + if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { "\n" @@ -117,18 +114,14 @@ fun MarkdownText ( } } if (meta?.isLive == true) { - val activity = LocalContext.current as Activity LaunchedEffect(meta.recent, meta.isLive) { switchTyping() } - DisposableEffect(Unit) { - val orientation = activity.resources.configuration.orientation - onDispose { - if (orientation == activity.resources.configuration.orientation) { - stopTyping() - } + DisposableEffectOnGone( + whenGone = { + stopTyping() } - } + ) } if (formattedText == null) { val annotatedText = buildAnnotatedString { @@ -183,7 +176,7 @@ fun MarkdownText ( .firstOrNull()?.let { annotation -> try { uriHandler.openUri(annotation.item) - } catch (e: ActivityNotFoundException) { + } catch (e: Exception) { // It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch // `tel:` scheme in url installed on a device (no phone app or contacts, maybe) Log.e(TAG, "Open url: ${e.stackTraceToString()}") @@ -228,12 +221,12 @@ fun ClickableText( } } }, shouldConsumeEvent = { pos -> - var consume = false - layoutResult.value?.let { layoutResult -> - consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos)) - } - consume + var consume = false + layoutResult.value?.let { layoutResult -> + consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos)) } + consume + } ) } @@ -250,3 +243,13 @@ fun ClickableText( } ) } + +private fun isRtl(s: CharSequence): Boolean { + for (element in s) { + val d = Character.getDirectionality(element) + if (d == Character.DIRECTIONALITY_RIGHT_TO_LEFT || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING || d == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE) { + return true + } + } + return false +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt similarity index 85% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt index 94f9632080..0505f58dc2 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist -import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -11,15 +10,14 @@ import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.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.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.annotatedStringResource -import chat.simplex.app.views.onboarding.ReadableTextWithLink -import chat.simplex.app.views.usersettings.MarkdownHelpView -import chat.simplex.app.views.usersettings.simplexTeamUri +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.helpers.annotatedStringResource +import chat.simplex.common.views.onboarding.ReadableTextWithLink +import chat.simplex.common.views.usersettings.MarkdownHelpView +import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.res.MR val bold = SpanStyle(fontWeight = FontWeight.Bold) @@ -76,12 +74,11 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatHelpLayout() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index c923b2fe14..de2d67b48c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist -import android.content.res.Configuration import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -13,18 +12,20 @@ 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.desktop.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.ui.theme.* -import chat.simplex.app.views.chat.* -import chat.simplex.app.views.chat.group.deleteGroupDialog -import chat.simplex.app.views.chat.group.leaveGroupDialog -import chat.simplex.app.views.chat.item.InvalidJSONView -import chat.simplex.app.views.chat.item.ItemAction -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.ContactConnectionInfoView +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.deleteGroupDialog +import chat.simplex.common.views.chat.group.leaveGroupDialog +import chat.simplex.common.views.chat.item.InvalidJSONView +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.ContactConnectionInfoView +import chat.simplex.common.model.* +import chat.simplex.common.platform.ntfManager import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.datetime.Clock @@ -205,7 +206,7 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState< painterResource(MR.images.ic_check), onClick = { markChatRead(chat, chatModel) - chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) + ntfManager.cancelNotificationsForChat(chat.id) showMenu.value = false } ) @@ -548,7 +549,7 @@ fun deleteGroup(groupInfo: GroupInfo, chatModel: ChatModel) { if (r) { chatModel.removeChat(groupInfo.id) chatModel.chatId.value = null - chatModel.controller.ntfManager.cancelNotificationsForChat(groupInfo.id) + ntfManager.cancelNotificationsForChat(groupInfo.id) } } } @@ -593,7 +594,7 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo if (res && newChatInfo != null) { chatModel.updateChatInfo(newChatInfo) if (!chatSettings.enableNtfs) { - chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id) + ntfManager.cancelNotificationsForChat(chat.id) } val current = currentState?.value if (current != null) { @@ -629,12 +630,11 @@ fun ChatListNavLinkLayout( Divider(Modifier.padding(horizontal = 8.dp)) } -@Preview -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatListNavLinkDirect() { SimpleXTheme { @@ -670,12 +670,11 @@ fun PreviewChatListNavLinkDirect() { } } -@Preview -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatListNavLinkGroup() { SimpleXTheme { @@ -711,12 +710,11 @@ fun PreviewChatListNavLinkGroup() { } } -@Preview -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatListNavLinkContactRequest() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3d93cab587..9c372020fc 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist -import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -19,19 +18,20 @@ import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.* -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.onboarding.WhatsNewView -import chat.simplex.app.views.onboarding.shouldShowWhatsNew -import chat.simplex.app.views.usersettings.SettingsView -import chat.simplex.app.views.usersettings.simplexTeamUri +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.onboarding.shouldShowWhatsNew +import chat.simplex.common.views.usersettings.SettingsView +import chat.simplex.common.views.usersettings.simplexTeamUri +import chat.simplex.common.platform.* +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import java.net.URI @Composable fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { @@ -178,7 +178,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user if (chatModel.chats.size > 0) { barButtons.add { IconButton({ showSearch = true }) { - Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb).capitalize(Locale.current), tint = MaterialTheme.colors.primary) + Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) } } } @@ -279,7 +279,7 @@ private fun BoxScope.unreadBadge(text: String? = "") { @Composable private fun ToggleFilterButton() { - val pref = remember { SimplexApp.context.chatModel.controller.appPrefs.showUnreadAndFavorites } + val pref = remember { ChatController.appPrefs.showUnreadAndFavorites } IconButton(onClick = { pref.set(!pref.get()) }) { Icon( painterResource(MR.images.ic_filter_list), @@ -306,6 +306,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 @@ -314,7 +343,7 @@ private fun ChatList(chatModel: ChatModel, search: String) { DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } } - val showUnreadAndFavorites = remember { chatModel.controller.appPrefs.showUnreadAndFavorites.state }.value + val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search) } } LazyColumn( modifier = Modifier.fillMaxWidth(), @@ -332,7 +361,7 @@ private fun ChatList(chatModel: ChatModel, search: String) { } private fun filteredChats(showUnreadAndFavorites: Boolean, searchText: String): List { - val chatModel = SimplexApp.context.chatModel + val chatModel = ChatModel val s = searchText.trim().lowercase() return if (s.isEmpty() && !showUnreadAndFavorites) chatModel.chats @@ -364,4 +393,3 @@ private fun filtered(chat: Chat): Boolean = private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean = cInfo.chatViewName.lowercase().contains(s.lowercase()) - diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index efa51a3790..04e0724c27 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist -import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -17,15 +16,15 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.* -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.ComposePreview -import chat.simplex.app.views.chat.ComposeState -import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.ComposePreview +import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.chat.item.MarkdownText +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.model.GroupInfo import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -312,12 +311,11 @@ fun ChatStatusImage(s: NetworkStatus?) { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatPreviewView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt index 39c0b583a3..7931034de3 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ContactConnectionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme @@ -7,14 +7,14 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity -import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow 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.ProfileImage +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.common.model.PendingContactConnection +import chat.simplex.common.model.getTimestampText import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 8d95226730..6ec03ad7f5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme @@ -11,10 +11,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow 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.helpers.ChatInfoImage +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.ChatInfoImage +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.getTimestampText import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ShareListNavLinkView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 5156144854..b9b8ea54bc 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist import SectionItemView import androidx.compose.foundation.layout.* @@ -9,9 +9,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.Indigo -import chat.simplex.app.views.helpers.ProfileImage +import chat.simplex.common.ui.theme.Indigo +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.common.model.* @Composable fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ShareListView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 3a3f929fcc..b3daa610e8 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -1,7 +1,5 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -13,14 +11,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.Chat +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.BackHandler import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow @@ -83,7 +80,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState if (chatModel.chats.size >= 8) { barButtons.add { IconButton({ showSearch = true }) { - Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb).capitalize(Locale.current), tint = MaterialTheme.colors.primary) + Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary) } } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt similarity index 90% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 5395c73f37..e8fbb66635 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -1,7 +1,6 @@ -package chat.simplex.app.views.chatlist +package chat.simplex.common.views.chatlist import SectionItemView -import android.util.Log import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -13,19 +12,17 @@ import androidx.compose.ui.* import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* -import chat.simplex.app.R -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.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.User +import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -91,10 +88,10 @@ fun UserPicker( } } val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() } - val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density } + val maxWidth = with(LocalDensity.current) { screenWidth() * density } Box(Modifier .fillMaxSize() - .offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) } + .offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) } .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING }) .padding(bottom = 10.dp, top = 10.dp) .graphicsLayer { @@ -168,9 +165,9 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues ) { UserProfileRow(u) if (u.activeUser) { - Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) } else if (u.hidden) { - Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) + Icon(painterResource(MR.images.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) } else if (unreadCount > 0) { Box( contentAlignment = Alignment.Center @@ -197,7 +194,7 @@ fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues fun UserProfileRow(u: User) { Row( Modifier - .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f) + .widthIn(max = screenWidth() * 0.7f) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/ChatArchiveView.kt similarity index 57% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/ChatArchiveView.kt index 69b8f49d1a..3963c69ae3 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/ChatArchiveView.kt @@ -1,47 +1,40 @@ -package chat.simplex.app.views.database +package chat.simplex.common.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 -import android.widget.Toast -import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext 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.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.datetime.* -import java.io.BufferedOutputStream import java.io.File +import java.net.URI import java.text.SimpleDateFormat import java.util.* @Composable fun ChatArchiveView(m: ChatModel, title: String, archiveName: String, archiveTime: Instant) { - val context = LocalContext.current - val archivePath = "${getFilesDirectory()}/$archiveName" - val saveArchiveLauncher = rememberSaveArchiveLauncher(archivePath) + val archivePath = filesDir.absolutePath + File.separator + archiveName + val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? -> + if (to != null) { + copyFileToFile(File(archivePath), to) {} + } + } ChatArchiveLayout( title, archiveTime, - saveArchive = { saveArchiveLauncher.launch(archivePath.substringAfterLast("/")) }, + saveArchive = { withApi { saveArchiveLauncher.launch(archivePath.substringAfterLast(File.separator)) }}, deleteArchiveAlert = { deleteArchiveAlert(m, archivePath) } ) } @@ -81,29 +74,6 @@ fun ChatArchiveLayout( } } -@Composable -private fun rememberSaveArchiveLauncher(chatArchivePath: String): ManagedActivityResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(), - onResult = { destination -> - val cxt = SimplexApp.context - try { - destination?.let { - val contentResolver = cxt.contentResolver - contentResolver.openOutputStream(destination)?.let { stream -> - val outputStream = BufferedOutputStream(stream) - File(chatArchivePath).inputStream().use { it.copyTo(outputStream) } - outputStream.close() - 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, "rememberSaveArchiveLauncher error saving archive $e") - } - } - ) - private fun deleteArchiveAlert(m: ChatModel, archivePath: String) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_chat_archive_question), @@ -122,12 +92,11 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) { ) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatArchiveLayout() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt similarity index 98% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 07be9dcef7..137d7f6fd5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.database +package chat.simplex.common.views.database import SectionBottomSpacer import SectionItemView @@ -23,13 +23,12 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.* -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.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index cf97b3c8c4..7101481681 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -1,10 +1,9 @@ -package chat.simplex.app.views.database +package chat.simplex.common.views.database import SectionBottomSpacer import SectionSpacer import SectionView -import android.content.Context -import android.util.Log +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions @@ -13,17 +12,13 @@ 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 androidx.compose.ui.text.font.FontWeight -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.AppPreferences -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.common.model.AppPreferences +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.AppVersionText import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -42,7 +37,6 @@ fun DatabaseErrorView( val dbKey = remember { mutableStateOf("") } var storedDBKey by remember { mutableStateOf(DatabaseUtils.ksDatabasePassword.get()) } var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) } - val context = LocalContext.current val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences)) } fun callRunChat(confirmMigrations: MigrationConfirmation? = null) { @@ -199,18 +193,14 @@ 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()}") } progressIndicator.value = false when (val status = chatDbStatus.value) { is DBMigrationResult.OK -> { - SimplexService.cancelPassphraseNotification() - when (prefs.notificationsMode.get()) { - NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start() } - NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp() - } + platform.androidChatStartedAfterBeingOff() } is DBMigrationResult.ErrorNotADatabase -> AlertManager.shared.showAlertMsg(generalGetString(MR.strings.wrong_passphrase_title), generalGetString(MR.strings.enter_correct_passphrase)) @@ -228,12 +218,11 @@ private fun runChat( } private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean { - val context = SimplexApp.context val startedAt = prefs.encryptionStartedAt.get() ?: return false /** Just in case there is any small difference between reported Java's [Clock.System.now] and Linux's time on a file */ val safeDiffInTime = 10_000L - val filesChat = File(context.dataDir.absolutePath + File.separator + "files_chat.db.bak") - val filesAgent = File(context.dataDir.absolutePath + File.separator + "files_agent.db.bak") + val filesChat = File(dataDir.absolutePath + File.separator + "${chatDatabaseFileName}.bak") + val filesAgent = File(dataDir.absolutePath + File.separator + "${agentDatabaseFileName}.bak") return filesChat.exists() && filesAgent.exists() && startedAt.toEpochMilliseconds() - safeDiffInTime <= filesChat.lastModified() && @@ -241,9 +230,8 @@ private fun shouldShowRestoreDbButton(prefs: AppPreferences): Boolean { } private fun restoreDb(restoreDbFromBackup: MutableState, prefs: AppPreferences) { - val context = SimplexApp.context - val filesChatBase = context.dataDir.absolutePath + File.separator + "files_chat.db" - val filesAgentBase = context.dataDir.absolutePath + File.separator + "files_agent.db" + val filesChatBase = dataDir.absolutePath + File.separator + chatDatabaseFileName + val filesAgentBase = dataDir.absolutePath + File.separator + agentDatabaseFileName try { Files.copy(Path("$filesChatBase.bak"), Path(filesChatBase), StandardCopyOption.REPLACE_EXISTING) Files.copy(Path("$filesAgentBase.bak"), Path(filesAgentBase), StandardCopyOption.REPLACE_EXISTING) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt similarity index 84% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index f82caf7e78..32d2379f75 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -1,18 +1,11 @@ -package chat.simplex.app.views.database +package chat.simplex.common.views.database import SectionBottomSpacer 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 -import android.widget.Toast -import androidx.activity.compose.ManagedActivityResultLauncher -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -21,25 +14,21 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource 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.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.* import kotlinx.datetime.* -import org.apache.commons.io.IOUtils import java.io.* +import java.net.URI +import java.nio.file.Files import java.text.SimpleDateFormat import java.util.* import kotlin.collections.ArrayList @@ -49,7 +38,6 @@ fun DatabaseView( m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit) ) { - val context = LocalContext.current val progressIndicator = remember { mutableStateOf(false) } val runChat = remember { m.chatRunning } val prefs = m.controller.appPrefs @@ -58,11 +46,18 @@ fun DatabaseView( val chatArchiveTime = remember { mutableStateOf(prefs.chatArchiveTime.get()) } val chatLastStart = remember { mutableStateOf(prefs.chatLastStart.get()) } val chatArchiveFile = remember { mutableStateOf(null) } - val saveArchiveLauncher = rememberSaveArchiveLauncher(chatArchiveFile) - val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(getAppFilesDirectory())) } - val importArchiveLauncher = rememberGetContentLauncher { uri: Uri? -> - if (uri != null) { - importArchiveAlert(m, uri, appFilesCountAndSize, progressIndicator) + val saveArchiveLauncher = rememberFileChooserLauncher(false) { to: URI? -> + val file = chatArchiveFile.value + if (file != null && to != null) { + copyFileToFile(File(file), to) { + chatArchiveFile.value = null + } + } + } + val appFilesCountAndSize = remember { mutableStateOf(directoryFileCountAndSize(appFilesDir.absolutePath)) } + val importArchiveLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) { + importArchiveAlert(m, to, appFilesCountAndSize, progressIndicator) } } LaunchedEffect(m.chatRunning) { @@ -129,7 +124,7 @@ fun DatabaseLayout( useKeyChain: Boolean, chatDbEncrypted: Boolean?, initialRandomDBPassphrase: SharedPreference, - importArchiveLauncher: ManagedActivityResultLauncher, + importArchiveLauncher: FileChooserLauncher, chatArchiveName: MutableState, chatArchiveTime: MutableState, chatLastStart: MutableState, @@ -204,7 +199,7 @@ fun DatabaseLayout( SettingsActionItem( painterResource(MR.images.ic_download), stringResource(MR.strings.import_database), - { importArchiveLauncher.launch("application/zip") }, + { withApi { importArchiveLauncher.launch("application/zip") }}, textColor = Color.Red, iconColor = Color.Red, disabled = operationsDisabled @@ -357,7 +352,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) { @@ -376,10 +371,7 @@ private fun startChat(m: ChatModel, runChat: MutableState, chatLastSta val ts = Clock.System.now() m.controller.appPrefs.chatLastStart.set(ts) chatLastStart.value = ts - when (m.controller.appPrefs.notificationsMode.get()) { - NotificationsMode.SERVICE.name -> CoroutineScope(Dispatchers.Default).launch { SimplexService.start() } - NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp() - } + platform.androidChatStartedAfterBeingOff() } catch (e: Error) { runChat.value = false AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_starting_chat), e.toString()) @@ -433,8 +425,7 @@ private fun stopChat(m: ChatModel, runChat: MutableState) { try { runChat.value = false stopChatAsync(m) - SimplexService.safeStopService(SimplexApp.context) - MessagesFetcherWorker.cancelAll() + platform.androidChatStopped() } catch (e: Error) { runChat.value = true AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_stopping_chat), e.toString()) @@ -459,14 +450,14 @@ private fun exportArchive( chatArchiveName: MutableState, chatArchiveTime: MutableState, chatArchiveFile: MutableState, - saveArchiveLauncher: ManagedActivityResultLauncher + saveArchiveLauncher: FileChooserLauncher ) { progressIndicator.value = true withApi { try { val archiveFile = exportChatArchive(m, chatArchiveName, chatArchiveTime, chatArchiveFile) chatArchiveFile.value = archiveFile - saveArchiveLauncher.launch(archiveFile.substringAfterLast("/")) + saveArchiveLauncher.launch(archiveFile.substringAfterLast(File.separator)) progressIndicator.value = false } catch (e: Error) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_exporting_chat_database), e.toString()) @@ -484,8 +475,8 @@ private suspend fun exportChatArchive( val archiveTime = Clock.System.now() val ts = SimpleDateFormat("yyyy-MM-dd'T'HHmmss", Locale.US).format(Date.from(archiveTime.toJavaInstant())) val archiveName = "simplex-chat.$ts.zip" - val archivePath = "${getFilesDirectory()}/$archiveName" - val config = ArchiveConfig(archivePath, parentTempDirectory = SimplexApp.context.cacheDir.toString()) + val archivePath = "${filesDir.absolutePath}${File.separator}$archiveName" + val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) m.controller.apiExportArchive(config) deleteOldArchive(m) m.controller.appPrefs.chatArchiveName.set(archiveName) @@ -499,7 +490,7 @@ private suspend fun exportChatArchive( private fun deleteOldArchive(m: ChatModel) { val chatArchiveName = m.controller.appPrefs.chatArchiveName.get() if (chatArchiveName != null) { - val file = File("${getFilesDirectory()}/$chatArchiveName") + val file = File("${filesDir.absolutePath}${File.separator}$chatArchiveName") val fileDeleted = file.delete() if (fileDeleted) { m.controller.appPrefs.chatArchiveName.set(null) @@ -510,39 +501,9 @@ private fun deleteOldArchive(m: ChatModel) { } } -@Composable -private fun rememberSaveArchiveLauncher(chatArchiveFile: MutableState): ManagedActivityResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument(), - onResult = { destination -> - val cxt = SimplexApp.context - try { - destination?.let { - val filePath = chatArchiveFile.value - if (filePath != null) { - val contentResolver = SimplexApp.context.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() - } - } - } catch (e: Error) { - Toast.makeText(cxt, generalGetString(MR.strings.error_saving_file), Toast.LENGTH_SHORT).show() - Log.e(TAG, "rememberSaveArchiveLauncher error saving archive $e") - } finally { - chatArchiveFile.value = null - } - } - ) - private fun importArchiveAlert( m: ChatModel, - importedArchiveUri: Uri, + importedArchiveURI: URI, appFilesCountAndSize: MutableState>, progressIndicator: MutableState ) { @@ -550,28 +511,28 @@ private fun importArchiveAlert( title = generalGetString(MR.strings.import_database_question), text = generalGetString(MR.strings.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one), confirmText = generalGetString(MR.strings.import_database_confirmation), - onConfirm = { importArchive(m, importedArchiveUri, appFilesCountAndSize, progressIndicator) }, + onConfirm = { importArchive(m, importedArchiveURI, appFilesCountAndSize, progressIndicator) }, destructive = true, ) } private fun importArchive( m: ChatModel, - importedArchiveUri: Uri, + importedArchiveURI: URI, appFilesCountAndSize: MutableState>, progressIndicator: MutableState ) { progressIndicator.value = true - val archivePath = saveArchiveFromUri(importedArchiveUri) + val archivePath = saveArchiveFromURI(importedArchiveURI) if (archivePath != null) { withApi { try { m.controller.apiDeleteStorage() try { - val config = ArchiveConfig(archivePath, parentTempDirectory = SimplexApp.context.cacheDir.toString()) + val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) val archiveErrors = m.controller.apiImportArchive(config) DatabaseUtils.ksDatabasePassword.remove() - appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory()) + appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) if (archiveErrors.isEmpty()) { operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.chat_database_imported), text = generalGetString(MR.strings.restart_the_app_to_use_imported_chat_database)) @@ -597,21 +558,21 @@ private fun importArchive( } } -private fun saveArchiveFromUri(importedArchiveUri: Uri): String? { +private fun saveArchiveFromURI(importedArchiveURI: URI): String? { return try { - val inputStream = SimplexApp.context.contentResolver.openInputStream(importedArchiveUri) - val archiveName = getFileName(importedArchiveUri) + val inputStream = importedArchiveURI.inputStream() + val archiveName = getFileName(importedArchiveURI) if (inputStream != null && archiveName != null) { - val archivePath = "${SimplexApp.context.cacheDir}/$archiveName" + val archivePath = "$databaseExportDir${File.separator}$archiveName" val destFile = File(archivePath) - IOUtils.copy(inputStream, FileOutputStream(destFile)) + Files.copy(inputStream, destFile.toPath()) archivePath } else { - Log.e(TAG, "saveArchiveFromUri null inputStream") + Log.e(TAG, "saveArchiveFromURI null inputStream") null } } catch (e: Exception) { - Log.e(TAG, "saveArchiveFromUri error: ${e.message}") + Log.e(TAG, "saveArchiveFromURI error: ${e.message}") null } } @@ -668,10 +629,10 @@ private fun setCiTTL( private fun afterSetCiTTL( m: ChatModel, progressIndicator: MutableState, - appFilesCountAndSize: MutableState> + appFilesCountAndSize: MutableState>, ) { progressIndicator.value = false - appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory()) + appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) withApi { try { val chats = m.controller.apiGetChats() @@ -694,7 +655,7 @@ private fun deleteFilesAndMediaAlert(appFilesCountAndSize: MutableState>) { deleteAppFiles() - appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory()) + appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) } private fun operationEnded(m: ChatModel, progressIndicator: MutableState, alert: () -> Unit) { @@ -703,12 +664,11 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState Unit, ) { showAlert { - Dialog(onDismissRequest = this::hideAlert) { + DefaultDialog(onDismissRequest = ::hideAlert) { Column( Modifier .background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp))) @@ -211,4 +208,4 @@ private fun alertText(text: String?): (@Composable () -> Unit)? { ) }) } -} \ No newline at end of file +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt index 15f507af62..6a400295ed 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/AnimationUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AnimationUtils.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.animation.core.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt similarity index 88% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 175d27c7c4..3a799eddf8 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -1,5 +1,6 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -12,12 +13,11 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale 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 androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.model.ChatInfo -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.base64ToBitmap +import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -25,7 +25,7 @@ import dev.icerock.moko.resources.ImageResource fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) { val icon = if (chatInfo is ChatInfo.Group) MR.images.ic_supervised_user_circle_filled - else MR.images.ic_account_circle_filled + else MR.images.ic_account_circle_filled ProfileImage(size, chatInfo.image, icon, iconColor) } @@ -70,7 +70,7 @@ fun ProfileImage( ) } } else { - val imageBitmap = base64ToBitmap(image).asImageBitmap() + val imageBitmap = base64ToBitmap(image) Image( imageBitmap, stringResource(MR.strings.image_descr_profile_image), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.kt new file mode 100644 index 0000000000..aa3c4560ea --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.kt @@ -0,0 +1,39 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.dp + +sealed class AttachmentOption { + object CameraPhoto: AttachmentOption() + object GalleryImage: AttachmentOption() + object GalleryVideo: AttachmentOption() + object File: AttachmentOption() +} + +@Composable +fun ChooseAttachmentView(attachmentOption: MutableState, hide: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .onFocusChanged { focusState -> + if (!focusState.hasFocus) hide() + } + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 30.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ChooseAttachmentButtons(attachmentOption, hide) + } + } +} + +@Composable +expect fun ChooseAttachmentButtons(attachmentOption: MutableState, hide: () -> Unit) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt index b7d42f493d..0877cb42e9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CloseSheetBar.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import android.content.res.Configuration import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -11,10 +10,10 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import chat.simplex.app.ui.theme.* +import chat.simplex.common.ui.theme.* @Composable fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> Unit = {}) { @@ -63,12 +62,11 @@ fun AppBarTitle(title: String, withPadding: Boolean = true, bottomPadding: Dp = ) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewCloseSheetBar() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CustomIcons.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomIcons.kt similarity index 99% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CustomIcons.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomIcons.kt index 1ed7d3a954..e3ce25bd3a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CustomIcons.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomIcons.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.material.icons.materialIcon import androidx.compose.material.icons.materialPath diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt index 32c52dc1d5..fb1df940d9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/CustomTimePicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -12,9 +12,9 @@ import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.model.CustomTimeUnit +import chat.simplex.common.model.timeText import chat.simplex.res.MR import com.sd.lib.compose.wheel_picker.* @@ -150,7 +150,7 @@ fun CustomTimePickerDialog( confirmButtonAction: (Int) -> Unit, cancel: () -> Unit ) { - Dialog(onDismissRequest = cancel) { + DefaultDialog(onDismissRequest = cancel) { Surface( shape = RoundedCornerShape(corner = CornerSize(25.dp)) ) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DataClasses.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DataClasses.kt similarity index 82% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DataClasses.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DataClasses.kt index 9fdf2b3966..351a7e9d44 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DataClasses.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DataClasses.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers interface ValueTitle { val value: T diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt similarity index 82% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index f200d4ab69..10641b6d81 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -1,19 +1,13 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import android.util.Log -import chat.simplex.app.* -import chat.simplex.app.model.* -import chat.simplex.app.views.usersettings.Cryptor +import chat.simplex.common.model.* +import chat.simplex.common.platform.* import kotlinx.serialization.* import java.io.File import java.security.SecureRandom object DatabaseUtils { - private val cryptor = Cryptor() - - private val appPreferences: AppPreferences by lazy { - ChatController.appPrefs - } + private val appPreferences: AppPreferences = ChatController.appPrefs private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword" private const val APP_PASSWORD_ALIAS: String = "appPassword" @@ -26,16 +20,16 @@ object DatabaseUtils { class KeyStoreItem(private val alias: String, val passphrase: SharedPreference, val initVector: SharedPreference) { fun get(): String? { return cryptor.decryptData( - passphrase.get()?.toByteArrayFromBase64() ?: return null, - initVector.get()?.toByteArrayFromBase64() ?: return null, + passphrase.get()?.toByteArrayFromBase64ForPassphrase() ?: return null, + initVector.get()?.toByteArrayFromBase64ForPassphrase() ?: return null, alias, ) } fun set(key: String) { val data = cryptor.encryptText(key, alias) - passphrase.set(data.first.toBase64String()) - initVector.set(data.second.toBase64String()) + passphrase.set(data.first.toBase64StringForPassphrase()) + initVector.set(data.second.toBase64StringForPassphrase()) } fun remove() { @@ -46,14 +40,14 @@ object DatabaseUtils { } private fun hasDatabase(rootDir: String): Boolean = - File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists() + File(rootDir + File.separator + chatDatabaseFileName).exists() && File(rootDir + File.separator + agentDatabaseFileName).exists() fun useDatabaseKey(): String { Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}") var dbKey = "" val useKeychain = appPreferences.storeDBPassphrase.get() if (useKeychain) { - if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) { + if (!hasDatabase(dataDir.absolutePath)) { dbKey = randomDatabasePassword() ksDatabasePassword.set(dbKey) appPreferences.initialRandomDBPassphrase.set(true) @@ -67,7 +61,7 @@ object DatabaseUtils { private fun randomDatabasePassword(): String { val s = ByteArray(32) SecureRandom().nextBytes(s) - return s.toBase64String().replace("\n", "") + return s.toBase64StringForPassphrase().replace("\n", "") } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt similarity index 98% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index 538ce4d86a..65eb11321e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -21,8 +21,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.views.database.PassphraseStrength -import chat.simplex.app.views.database.validKey +import chat.simplex.common.views.database.PassphraseStrength +import chat.simplex.common.views.database.validKey import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.kt new file mode 100644 index 0000000000..7e916e0ea7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.runtime.Composable + +@Composable +expect fun DefaultDialog( + onDismissRequest: () -> Unit, + content: @Composable () -> Unit +) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt similarity index 70% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt index 71af738bda..0ae69d82f6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -8,10 +8,26 @@ 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.* + +expect interface DefaultExposedDropdownMenuBoxScope { + @Composable + open fun DefaultExposedDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit + ) +} + +@Composable +expect fun DefaultExposedDropdownMenuBox( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit +) @Composable fun DefaultDropdownMenu( @@ -25,7 +41,7 @@ fun DefaultDropdownMenu( DropdownMenu( expanded = showMenu.value, onDismissRequest = { showMenu.value = false }, - Modifier + modifier = Modifier .widthIn(min = 250.dp) .background(MaterialTheme.colors.surface) .padding(vertical = 4.dp), @@ -37,7 +53,7 @@ fun DefaultDropdownMenu( } @Composable -fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( +fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( expanded: MutableState, modifier: Modifier = Modifier, dropdownMenuItems: (@Composable () -> Unit)? @@ -45,7 +61,7 @@ fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( MaterialTheme( shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp))) ) { - ExposedDropdownMenu( + DefaultExposedDropdownMenu( modifier = Modifier .widthIn(min = 200.dp) .background(MaterialTheme.colors.surface) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultSwitch.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultSwitch.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultSwitch.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultSwitch.kt index 613caed54d..75abc67b46 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultSwitch.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultSwitch.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.* diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 44a56c441d..0162ac7e78 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -1,6 +1,5 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import chat.simplex.app.R import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -11,7 +10,7 @@ import androidx.compose.ui.graphics.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp -import chat.simplex.app.ui.theme.* +import chat.simplex.common.ui.theme.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Enums.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt similarity index 61% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Enums.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt index cad5d4c53b..67f82e5279 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Enums.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt @@ -1,18 +1,18 @@ @file:UseSerializers(UriSerializer::class) -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import android.net.Uri import androidx.compose.runtime.saveable.Saver import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import java.net.URI sealed class SharedContent { data class Text(val text: String): SharedContent() - data class Media(val text: String, val uris: List): SharedContent() - data class File(val text: String, val uri: Uri): SharedContent() + data class Media(val text: String, val uris: List): SharedContent() + data class File(val text: String, val uri: URI): SharedContent() } enum class AnimatedViewState { @@ -37,16 +37,16 @@ enum class AnimatedViewState { } -@Serializer(forClass = Uri::class) -object UriSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: Uri) = encoder.encodeString(value.toString()) - override fun deserialize(decoder: Decoder): Uri = Uri.parse(decoder.decodeString()) +@Serializer(forClass = URI::class) +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("URI", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: URI) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): URI = URI(decoder.decodeString()) } @Serializable sealed class UploadContent { - @Serializable data class SimpleImage(val uri: Uri): UploadContent() - @Serializable data class AnimatedImage(val uri: Uri): UploadContent() - @Serializable data class Video(val uri: Uri, val duration: Int): UploadContent() + @Serializable data class SimpleImage(val uri: URI): UploadContent() + @Serializable data class AnimatedImage(val uri: URI): UploadContent() + @Serializable data class Video(val uri: URI, val duration: Int): UploadContent() } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index eb563dbd0f..2f24ff4144 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -12,10 +12,9 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.usersettings.SettingsActionItemWithContent import chat.simplex.res.MR +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.usersettings.SettingsActionItemWithContent @Composable fun ExposedDropDownSettingRow( @@ -30,7 +29,7 @@ fun ExposedDropDownSettingRow( ) { SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) { val expanded = remember { mutableStateOf(false) } - ExposedDropdownMenuBox( + DefaultExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value && enabled.value diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt similarity index 98% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt index daf323dc0d..4df48ca0af 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/GestureDetector.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt @@ -14,9 +14,9 @@ * limitations under the License. */ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import android.util.Log +import chat.simplex.common.platform.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.* @@ -28,7 +28,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.Density -import chat.simplex.app.TAG +import chat.simplex.common.platform.TAG import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlin.math.PI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GetImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GetImageView.kt new file mode 100644 index 0000000000..308038ec5e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GetImageView.kt @@ -0,0 +1,13 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.ImageBitmap +import java.net.URI + +@Composable +expect fun GetImageBottomSheet( + imageBitmap: MutableState, + onImageChange: (ImageBitmap) -> Unit, + hideBottomSheet: () -> Unit +) + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index 47efef1da3..3d59aac446 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -1,7 +1,6 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import android.content.res.Configuration -import android.graphics.BitmapFactory +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -10,17 +9,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource 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.LinkPreview -import chat.simplex.app.ui.theme.* +import chat.simplex.common.model.LinkPreview +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -58,11 +55,11 @@ suspend fun getLinkPreview(url: String): LinkPreview? { imageUri = normalizeImageUri(u, imageUri) try { val stream = URL(imageUri).openStream() - val image = resizeImageToStrSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) -// TODO add once supported in iOS -// val description = ogTags.firstOrNull { -// it.attr("property") == "og:description" -// }?.attr("content") ?: "" + val image = resizeImageToStrSize(stream.use(::loadImageBitmap), maxDataSize = 14000) + // TODO add once supported in iOS + // val description = ogTags.firstOrNull { + // it.attr("property") == "og:description" + // }?.attr("content") ?: "" if (title != null) { return@withContext LinkPreview(url, title, description = "", image) } @@ -96,7 +93,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel ) } } else { - val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() + val imageBitmap = base64ToBitmap(linkPreview.image) Image( imageBitmap, stringResource(MR.strings.image_descr_link_preview), @@ -127,7 +124,7 @@ fun ComposeLinkView(linkPreview: LinkPreview?, cancelPreview: () -> Unit, cancel fun ChatItemLinkView(linkPreview: LinkPreview) { Column { Image( - base64ToBitmap(linkPreview.image).asImageBitmap(), + base64ToBitmap(linkPreview.image), stringResource(MR.strings.image_descr_link_preview), modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth, @@ -174,12 +171,11 @@ private fun normalizeImageUri(u: URL, imageUri: String) = when { } }*/ -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "ChatItemLinkView (Dark Mode)" -) +)*/ @Composable fun PreviewChatItemLinkView() { SimpleXTheme { @@ -187,12 +183,11 @@ fun PreviewChatItemLinkView() { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "ComposeLinkView (Dark Mode)" -) +)*/ @Composable fun PreviewComposeLinkView() { SimpleXTheme { @@ -200,7 +195,7 @@ fun PreviewComposeLinkView() { } } -@Preview(showBackground = true) +@Preview @Composable fun PreviewComposeLinkViewLoading() { SimpleXTheme { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt new file mode 100644 index 0000000000..b6b512e03a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -0,0 +1,60 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.ui.Modifier +import chat.simplex.common.model.ChatController +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.BackHandler +import chat.simplex.common.views.localauth.LocalAuthView +import chat.simplex.common.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) { } + } +} + +expect fun authenticate( + promptTitle: String, + promptSubtitle: String, + selfDestruct: Boolean = false, + usingLAMode: LAMode = ChatModel.controller.appPrefs.laMode.get(), + completed: (LAResult) -> Unit +) + +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(ChatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && ChatController.appPrefs.selfDestruct.get()) { + close() + completed(it) + }) + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ModalView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 724f0907fa..598ceaab8c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -1,24 +1,15 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import android.R -import android.util.Log -import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.capitalize -import androidx.compose.ui.text.intl.Locale -import chat.simplex.app.TAG -import chat.simplex.app.ui.theme.isInDarkTheme -import chat.simplex.app.ui.theme.themedBackground +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.themedBackground import java.util.concurrent.atomic.AtomicBoolean @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt index f60d644a88..6990a69ebd 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Modifiers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Modifiers.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.offset diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt similarity index 98% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index a7afb9441f..4b6c70df44 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource @@ -24,7 +24,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.R import chat.simplex.res.MR import kotlinx.coroutines.delay diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Section.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 5c7a40e282..377eb7a8ae 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -8,16 +8,16 @@ 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.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.ValueTitleDesc -import chat.simplex.app.views.helpers.ValueTitle -import chat.simplex.app.views.usersettings.SettingsActionItemWithContent +import chat.simplex.common.platform.screenWidth +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.ValueTitleDesc +import chat.simplex.common.views.helpers.ValueTitle +import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import chat.simplex.res.MR @Composable @@ -238,13 +238,13 @@ fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color @Composable fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) { SectionItemViewSpaceBetween(onClick) { - val configuration = LocalConfiguration.current + val screenWidthDp = screenWidth() Text(title) Text( value, Modifier .padding(start = 10.dp) - .widthIn(max = (configuration.screenWidthDp / 2).dp), + .widthIn(max = (screenWidthDp / 2)), maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colors.secondary diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt similarity index 94% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 6202ded6de..5ab0e68c6b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -1,5 +1,6 @@ -package chat.simplex.app.ui.theme +package chat.simplex.common.views.helpers +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -13,8 +14,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.res.MR @Composable @@ -90,7 +91,7 @@ fun SimpleButtonFrame(click: () -> Unit, modifier: Modifier = Modifier, disabled @Preview @Composable -fun PreviewCloseSheetBar() { +fun PreviewShareButton() { SimpleXTheme { SimpleButton(text = "Share", icon = painterResource(MR.images.ic_share), click = {}) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index 9076c8ecdc..7ebbb7e5cb 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -1,11 +1,9 @@ -package chat.simplex.app.views.helpers +package chat.simplex.common.views.helpers -import android.util.Log import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -13,16 +11,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* -import androidx.compose.ui.text.TextStyle 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.ui.theme.* -import chat.simplex.app.views.chat.item.MarkdownText -import com.google.accompanist.insets.navigationBarsWithImePadding +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.model.FormattedText +import chat.simplex.common.platform.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.Serializable import java.lang.Exception @@ -54,7 +49,7 @@ fun TextEditor( .fillMaxWidth() .padding(contentPadding) .heightIn(min = 52.dp), -// .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(26.dp)), + // .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(26.dp)), contentAlignment = Alignment.Center, ) { val modifier = modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt new file mode 100644 index 0000000000..ec25a8811c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -0,0 +1,323 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.* +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.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 java.io.* +import java.net.URI +import java.nio.file.Files +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 +} + +// 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.localized() +} + +expect fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString + +@Composable +fun annotatedStringResource(id: StringResource): AnnotatedString { + val density = LocalDensity.current + return remember(id) { + escapedHtmlToAnnotatedString(id.localized(), density) + } +} + +// 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 + +expect fun getAppFileUri(fileName: String): URI + +// https://developer.android.com/training/data-storage/shared/documents-files#bitmap +expect fun getLoadedImage(file: CIFile?): ImageBitmap? + +expect fun getFileName(uri: URI): String? + +expect fun getAppFilePath(uri: URI): String? + +expect fun getFileSize(uri: URI): Long? + +expect fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean = true): ImageBitmap? + +expect fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean = true): Any? + +fun getThemeFromUri(uri: URI, withAlertOnException: Boolean = true): ThemeOverrides? { + uri.inputStream().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: ImageBitmap): 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(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) + uri.inputStream().use { input -> + output.use { output -> + input?.copyTo(output) + } + } + fileToSave + } catch (e: Exception) { + Log.e(TAG, "Util.kt saveAnimImage error: ${e.message}") + null + } +} + +expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? + +fun saveFileFromUri(uri: URI): String? { + return try { + val inputStream = uri.inputStream() + val fileToSave = getFileName(uri) + if (inputStream != null && fileToSave != null) { + val destFileName = uniqueCombine(fileToSave) + val destFile = File(getAppFilePath(destFileName)) + Files.copy(inputStream, destFile.toPath()) + 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(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(TAG, "Util.kt removeFile error") + } + return fileDeleted +} + +fun deleteAppFiles() { + val dir = appFilesDir + 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: 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 + } +} + +expect fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true): VideoPlayerInterface.PreviewAndDuration + +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 = blendARGB(color, this, alpha) + +fun blendARGB( + color1: Color, color2: Color, + ratio: Float +): Color { + val inverseRatio = 1 - ratio + val a: Float = color1.alpha * inverseRatio + color2.alpha * ratio + val r: Float = color1.red * inverseRatio + color2.red * ratio + val g: Float = color1.green * inverseRatio + color2.green * ratio + val b: Float = color1.blue * inverseRatio + color2.blue * ratio + return Color(r, g, b, a) +} + +expect fun ByteArray.toBase64StringForPassphrase(): String + +// Android's default implementation that was used before multiplatform, adds non-needed characters at the end of string +// which can be bypassed by: +// fun String.toByteArrayFromBase64(): ByteArray = Base64.getDecoder().decode(this.trimEnd { it == '\n' || it == ' ' }) +expect fun String.toByteArrayFromBase64ForPassphrase(): ByteArray + +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: Exception/*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) { + DisposableEffect(Unit) { + always() + val orientation = screenOrientation() + onDispose { + whenDispose() + if (orientation == screenOrientation()) { + whenGone() + } + } + } +} + +@Composable +fun DisposableEffectOnRotate(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenRotate: () -> Unit) { + DisposableEffect(Unit) { + always() + val orientation = screenOrientation() + onDispose { + whenDispose() + if (orientation != screenOrientation()) { + whenRotate() + } + } + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt similarity index 81% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 9187c85d9b..0719c5da37 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -1,20 +1,19 @@ -package chat.simplex.app.views.localauth +package chat.simplex.common.views.localauth -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf 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.views.database.deleteChatAsync -import chat.simplex.app.views.database.stopChatAsync -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.helpers.DatabaseUtils.ksSelfDestructPassword -import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword -import chat.simplex.app.views.onboarding.OnboardingStage +import chat.simplex.common.views.database.deleteChatAsync +import chat.simplex.common.views.database.stopChatAsync +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.helpers.DatabaseUtils.ksSelfDestructPassword +import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.Profile +import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.delay @Composable fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) { @@ -43,7 +42,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( deleteChatAsync(m) ksAppPassword.set(password) ksSelfDestructPassword.remove() - m.controller.ntfManager.cancelAllNotifications() + ntfManager.cancelAllNotifications() val selfDestructPref = m.controller.appPrefs.selfDestruct val displayNamePref = m.controller.appPrefs.selfDestructDisplayName val displayName = displayNamePref.get() @@ -52,7 +51,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/localauth/PasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/PasscodeView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt index 42186f20c9..3e35bfb80a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/PasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt @@ -1,19 +1,18 @@ -package chat.simplex.app.views.localauth +package chat.simplex.common.views.localauth -import android.content.res.Configuration import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text 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 androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.ui.theme.SimpleButton -import chat.simplex.app.views.helpers.* +import chat.simplex.common.platform.ScreenOrientation +import chat.simplex.common.platform.screenOrientation +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.SimpleButton +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @Composable @@ -91,7 +90,7 @@ fun PasscodeView( } } - if (LocalContext.current.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + if (screenOrientation() == ScreenOrientation.PORTRAIT) { VerticalLayout() } else { HorizontalLayout() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/PasswordEntry.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt similarity index 99% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/PasswordEntry.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt index 98a880b4f0..ad7c26deb7 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/PasswordEntry.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.localauth +package chat.simplex.common.views.localauth import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/SetAppPasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt similarity index 84% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/SetAppPasscodeView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt index 5bac848f76..18437dbf98 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/localauth/SetAppPasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt @@ -1,12 +1,11 @@ -package chat.simplex.app.views.localauth +package chat.simplex.common.views.localauth -import androidx.activity.compose.BackHandler import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import chat.simplex.app.R -import chat.simplex.app.views.helpers.DatabaseUtils -import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.common.platform.BackHandler +import chat.simplex.common.views.helpers.DatabaseUtils +import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt similarity index 74% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactLearnMore.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt index dd1963921d..2913f6ac79 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt @@ -1,14 +1,13 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import androidx.compose.foundation.* import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.R -import chat.simplex.app.views.helpers.AppBarTitle -import chat.simplex.app.views.onboarding.ReadableText -import chat.simplex.app.views.onboarding.ReadableTextWithLink +import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index 39bd6cd333..e7de6154ea 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -1,9 +1,9 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionSpacer import SectionView -import android.content.res.Configuration +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -12,22 +12,23 @@ import dev.icerock.moko.resources.compose.painterResource import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager 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.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.common.platform.shareText +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR @Composable fun AddContactView(connReqInvitation: String, connIncognito: Boolean) { + val clipboard = LocalClipboardManager.current AddContactLayout( connReq = connReqInvitation, connIncognito = connIncognito, - share = { shareText(connReqInvitation) }, + share = { clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.shared.showModal { Column( @@ -152,12 +153,11 @@ fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean } } -@Preview -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewAddContactView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt similarity index 87% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 2609098ebd..c89e0e43fb 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -1,6 +1,6 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat -import android.net.Uri +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,25 +15,23 @@ 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.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.ui.theme.* -import chat.simplex.app.views.ProfileNameField -import chat.simplex.app.views.chat.group.AddGroupMembersView -import chat.simplex.app.views.chatlist.setGroupMembers -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.isValidDisplayName -import chat.simplex.app.views.onboarding.ReadableText -import chat.simplex.app.views.usersettings.DeleteImageButton -import chat.simplex.app.views.usersettings.EditImageButton -import com.google.accompanist.insets.ProvideWindowInsets -import com.google.accompanist.insets.navigationBarsWithImePadding +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.ProfileNameField +import chat.simplex.common.views.chat.group.AddGroupMembersView +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.isValidDisplayName +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.usersettings.DeleteImageButton +import chat.simplex.common.views.usersettings.EditImageButton +import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.net.URI @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { @@ -63,7 +61,7 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } val fullName = rememberSaveable { mutableStateOf("") } - val chosenImage = rememberSaveable { mutableStateOf(null) } + val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt new file mode 100644 index 0000000000..a2bc2c4df4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.kt @@ -0,0 +1,11 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.runtime.* +import chat.simplex.common.model.ChatModel + +enum class ConnectViaLinkTab { + SCAN, PASTE +} + +@Composable +expect fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt similarity index 85% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ContactConnectionInfoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 3317472cc5..07d937823b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -1,9 +1,9 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionDividerSpaced import SectionView -import android.content.res.Configuration +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -11,16 +11,18 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager 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.ui.theme.* -import chat.simplex.app.views.chat.LocalAliasEditor -import chat.simplex.app.views.chatlist.deleteContactConnectionAlert -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.SettingsActionItem +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.LocalAliasEditor +import chat.simplex.common.views.chatlist.deleteContactConnectionAlert +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.PendingContactConnection +import chat.simplex.common.platform.shareText import chat.simplex.res.MR @Composable @@ -45,6 +47,7 @@ fun ContactConnectionInfoView( } } } + val clipboard = LocalClipboardManager.current ContactConnectionInfoLayout( connReq = connReqInvitation, contactConnection, @@ -52,7 +55,7 @@ fun ContactConnectionInfoView( focusAlias, deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) }, - share = { if (connReqInvitation != null) shareText(connReqInvitation) }, + share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.shared.showModal { Column( @@ -137,12 +140,11 @@ private fun setContactAlias(contactConnection: PendingContactConnection, localAl } } -@Preview -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable private fun PreviewContactConnectionInfoView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index b0b7b923d7..3d6cfb2f91 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -9,12 +9,10 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.helpers.ModalManager -import chat.simplex.app.views.helpers.withApi -import chat.simplex.app.views.usersettings.UserAddressView +import chat.simplex.common.model.ChatModel +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.withApi +import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.res.MR enum class CreateLinkTab { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt similarity index 92% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index fd7edeaa29..a51d070f85 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -1,8 +1,8 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat -import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -20,14 +20,12 @@ 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.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.core.graphics.ColorUtils -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -56,7 +54,11 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow Unit) { val connectionLink = remember { mutableStateOf("") } - val context = LocalContext.current - val clipboard = getSystemService(context, ClipboardManager::class.java) + val clipboard = LocalClipboardManager.current PasteToConnectLayout( chatModel.incognito.value, connectionLink = connectionLink, pasteFromClipboard = { - connectionLink.value = clipboard?.primaryClip?.getItemAt(0)?.coerceToText(context) as? String ?: return@PasteToConnectLayout + connectionLink.value = clipboard.getText()?.text ?: return@PasteToConnectLayout }, connectViaLink = { connReqUri -> try { - val uri = Uri.parse(connReqUri) + val uri = URI(connReqUri) withUriAction(uri) { linkType -> val action = suspend { Log.d(TAG, "connectViaUri: connecting") @@ -114,11 +109,10 @@ fun PasteToConnectLayout( } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" -) +)*/ @Composable fun PreviewPasteToConnectTextbox() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt similarity index 64% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index c3830bd91b..9882caa55d 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -1,6 +1,6 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat -import android.graphics.Bitmap +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.runtime.* @@ -8,16 +8,11 @@ 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.common.platform.* +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -35,7 +30,6 @@ fun QRCode( 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, @@ -55,7 +49,7 @@ fun QRCode( } } -fun qrCodeBitmap(content: String, size: Int = 1024): Bitmap { +fun qrCodeBitmap(content: String, size: Int = 1024): ImageBitmap { 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) @@ -69,33 +63,10 @@ fun qrCodeBitmap(content: String, size: Int = 1024): Bitmap { val renderer = QrCodeGeneratorImage(pixelsPerModule + 1) renderer.borderModule = borderModule renderer.render(qrCode) - return ConvertBitmap.grayToBitmap(renderer.gray, Bitmap.Config.RGB_565).scale(size, size) + return renderer.gray.toImageBitmap().scale(size, size) } -fun Bitmap.replaceColor(from: Int, to: Int): Bitmap { - val pixels = IntArray(width * height) - getPixels(pixels, 0, width, 0, 0, width, height) - var i = 0 - while (i < pixels.size) { - if (pixels[i] == from) { - pixels[i] = to - } - i++ - } - 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) -} +expect fun ImageBitmap.replaceColor(from: Int, to: Int): ImageBitmap @Preview @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.kt new file mode 100644 index 0000000000..66ba595e17 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.runtime.* + +@Composable +expect fun QRCodeScanner(onBarcode: (String) -> Unit) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt similarity index 58% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index 2bbef58cf8..b6bcf96749 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -1,71 +1,59 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import SectionBottomSpacer -import android.Manifest -import android.content.res.Configuration -import android.net.Uri -import android.util.Log +import androidx.compose.desktop.ui.tooling.preview.Preview +import chat.simplex.common.platform.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.common.platform.TAG +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.json +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.net.URI @Composable -fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - 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() +expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) + +@Composable +fun QRCodeScanner(close: () -> Unit) { + QRCodeScanner { connReqUri -> + try { + val uri = URI(connReqUri) + withUriAction(uri) { linkType -> + val action = suspend { + Log.d(TAG, "connectViaUri: connecting") + if (connectViaUri(ChatModel, linkType, uri)) { + close() } - } catch (e: RuntimeException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_QR_code), - text = generalGetString(MR.strings.this_QR_code_is_not_a_link) - ) } + 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 { @@ -77,9 +65,9 @@ sealed class CReqClientData { @Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData() } -fun withUriAction(uri: Uri, run: suspend (ConnectionLinkType) -> Unit) { +fun withUriAction(uri: URI, run: suspend (ConnectionLinkType) -> Unit) { val action = uri.path?.drop(1)?.replace("/", "") - val data = uri.toString().replaceFirst("#/", "/").toUri().getQueryParameter("data") + val data = URI(uri.toString().replaceFirst("#/", "/")).getQueryParameter("data") val type = when { data != null -> { val parsed = runCatching { @@ -90,6 +78,7 @@ fun withUriAction(uri: Uri, run: suspend (ConnectionLinkType) -> Unit) { else -> null } } + action == "contact" -> ConnectionLinkType.CONTACT action == "invitation" -> ConnectionLinkType.INVITATION else -> null @@ -104,7 +93,7 @@ fun withUriAction(uri: Uri, run: suspend (ConnectionLinkType) -> Unit) { } } -suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: Uri): Boolean { +suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI): Boolean { val r = chatModel.controller.apiConnect(uri.toString()) if (r) { AlertManager.shared.showAlertMsg( @@ -121,7 +110,7 @@ suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: } @Composable -fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable () -> Unit) { +fun ConnectContactLayout(chatModelIncognito: Boolean, close: () -> Unit) { Column( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), verticalArrangement = Arrangement.spacedBy(12.dp) @@ -138,7 +127,7 @@ fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable .fillMaxWidth() .aspectRatio(ratio = 1F) .padding(bottom = 12.dp) - ) { qrCodeScanner() } + ) { 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 @@ -147,18 +136,22 @@ fun ConnectContactLayout(chatModelIncognito: Boolean, qrCodeScanner: @Composable } } -@Preview -@Preview( +fun URI.getQueryParameter(param: String): String? { + if (!query.contains("$param=")) return null + return query.substringAfter("$param=").substringBefore("&") +} + +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( chatModelIncognito = false, - qrCodeScanner = { Surface {} }, + close = {}, ) } } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/CreateSimpleXAddress.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index af8b93c0f3..b8eb064b8c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -1,7 +1,6 @@ -package chat.simplex.app.views.onboarding +package chat.simplex.common.views.onboarding import SectionBottomSpacer -import android.util.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -9,29 +8,32 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource 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.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCode +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.UserContactLinkRec +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode import chat.simplex.res.MR @Composable fun CreateSimpleXAddress(m: ChatModel) { var progressIndicator by remember { mutableStateOf(false) } val userAddress = remember { m.userAddress } + val clipboard = LocalClipboardManager.current + val uriHandler = LocalUriHandler.current CreateSimpleXAddressLayout( userAddress.value, - share = { address: String -> shareText(address) }, + share = { address: String -> clipboard.shareText(address) }, sendEmail = { address -> - sendEmail( + uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), generalGetString(MR.strings.email_invite_body).format(address.connReqContact) ) diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index c8bc7b5edf..a506c8daff 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -1,6 +1,6 @@ -package chat.simplex.app.views.onboarding +package chat.simplex.common.views.onboarding -import android.content.res.Configuration +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -8,19 +8,18 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* 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.SimplexApp -import chat.simplex.app.model.User -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ChatController +import chat.simplex.common.model.User +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.MarkdownText +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource -import dev.icerock.moko.resources.compose.stringResource @Composable fun HowItWorks(user: User?, onboardingStage: MutableState? = null) { @@ -87,16 +86,15 @@ fun ReadableMarkdownText(text: String, textAlign: TextAlign = TextAlign.Start, p formattedText = remember(text) { parseToMarkdown(text) }, modifier = Modifier.padding(padding), style = TextStyle(textAlign = textAlign, lineHeight = 22.sp, fontSize = 16.sp), - linkMode = SimplexApp.context.chatModel.controller.appPrefs.simplexLinkMode.get(), + linkMode = ChatController.appPrefs.simplexLinkMode.get(), ) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewHowItWorks() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt similarity index 82% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt index 997d9edb4b..e3190f8756 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/OnboardingView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt @@ -1,14 +1,14 @@ -package chat.simplex.app.views.onboarding +package chat.simplex.common.views.onboarding import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.runtime.* 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 com.google.accompanist.insets.ProvideWindowInsets +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.ProvideWindowInsets +import chat.simplex.common.views.CreateProfilePanel +import chat.simplex.common.platform.getKeyboardState import kotlinx.coroutines.launch enum class OnboardingStage { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt similarity index 75% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index 9f3b87194e..af640d5b48 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -1,7 +1,5 @@ -package chat.simplex.app.views.onboarding +package chat.simplex.common.views.onboarding -import android.Manifest -import android.os.Build import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -15,15 +13,11 @@ 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 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.common.model.ChatModel +import chat.simplex.common.model.NotificationsMode +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.changeNotificationsMode import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @@ -57,22 +51,7 @@ fun SetNotificationsMode(m: ChatModel) { } @Composable -fun SetNotificationsModeAdditions() { - if (Build.VERSION.SDK_INT >= 33) { - val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) - LaunchedEffect(notificationsPermissionState.hasPermission) { - if (notificationsPermissionState.hasPermission) { - SimplexApp.context.chatModel.controller.ntfManager.createNtfChannelsMaybeShowAlert() - } else { - notificationsPermissionState.launchPermissionRequest() - } - } - } else { - LaunchedEffect(Unit) { - SimplexApp.context.chatModel.controller.ntfManager.createNtfChannelsMaybeShowAlert() - } - } -} +expect fun SetNotificationsModeAdditions() @Composable private fun NotificationButton(currentMode: MutableState, mode: NotificationsMode, title: StringResource, description: StringResource) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 9c47931a40..ddb1e81ec4 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -1,6 +1,6 @@ -package chat.simplex.app.views.onboarding +package chat.simplex.common.views.onboarding -import android.content.res.Configuration +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,14 +14,10 @@ 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.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* -import chat.simplex.app.R -import chat.simplex.app.SimplexApp -import chat.simplex.app.model.ChatModel -import chat.simplex.app.model.User -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @@ -132,7 +128,7 @@ fun OnboardingActionButton( onclick?.invoke() onboardingStage.value = onboarding if (onboarding != null) { - SimplexApp.context.chatModel.controller.appPrefs.onboardingStage.set(onboarding) + ChatController.appPrefs.onboardingStage.set(onboarding) } }, modifier) { Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary, fontSize = 20.sp) @@ -143,12 +139,11 @@ fun OnboardingActionButton( } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewSimpleXInfo() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/WhatsNewView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 8a2b7f1835..074dd0656a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -1,8 +1,5 @@ -package chat.simplex.app.views.onboarding +package chat.simplex.common.views.onboarding -import android.content.res.Configuration -import android.os.Build -import androidx.annotation.IntegerRes import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,19 +11,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource -import androidx.compose.ui.res.stringResource 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.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.BuildConfig -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -424,12 +417,11 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewWhatsNewView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index 0c2341c69b..863266b387 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -1,9 +1,10 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionCustomFooter import SectionItemView import SectionView +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -17,14 +18,12 @@ import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource 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.model.* -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatModel import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource import java.text.DecimalFormat @Composable @@ -254,7 +253,7 @@ fun IntSettingRow(title: String, selection: MutableState, values: List Text(title) - ExposedDropdownMenuBox( + DefaultExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value @@ -313,7 +312,7 @@ fun TimeoutSettingRow(title: String, selection: MutableState, values: List Text(title) - ExposedDropdownMenuBox( + DefaultExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value @@ -407,7 +406,7 @@ fun showUpdateNetworkSettingsDialog(action: () -> Unit) { ) } -@Preview(showBackground = true) +@Preview @Composable fun PreviewAdvancedNetworkSettingsLayout() { SimpleXTheme { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt new file mode 100644 index 0000000000..e1e35ace8d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -0,0 +1,262 @@ +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionItemView +import SectionItemViewSpaceBetween +import SectionSpacer +import SectionView +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.MaterialTheme.colors +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* +import chat.simplex.res.MR +import com.godaddy.android.colorpicker.* +import kotlinx.serialization.encodeToString +import java.io.File +import java.net.URI +import java.util.* +import kotlin.collections.ArrayList + +@Composable +expect fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) + +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 = 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 = rememberFileChooserLauncher(false) { to: URI? -> + val themeValue = theme.value + if (themeValue != null && to != null) { + copyBytesToFile(themeValue.byteInputStream(), to) { + theme.value = null + } + } + } + SectionItemView({ + val overrides = ThemeManager.currentThemeOverridesForExport(isInDarkTheme) + theme.value = yaml.encodeToString(overrides) + withApi { exportThemeLauncher.launch("simplex.theme")} + }) { + Text(generalGetString(MR.strings.export_theme), color = colors.primary) + } + val importThemeLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) { + val theme = getThemeFromUri(to) + if (theme != null) { + ThemeManager.saveAndApplyThemeOverrides(theme, isInDarkTheme) + } + } + } + // Can not limit to YAML mime type since it's unsupported by Android + SectionItemView({ withApi { 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(modifier = Modifier + .fillMaxWidth() + .height(300.dp), + color = HsvColor.from(color = initialColor), 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))) + //} +} + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt index 38d6a88eb8..d4219a51c5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionItemView @@ -14,9 +14,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt similarity index 90% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/DeveloperView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index a3e51c1dab..c69ba20939 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionTextFooter @@ -12,10 +12,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.R -import chat.simplex.app.model.ChatModel -import chat.simplex.app.views.TerminalView -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.views.TerminalView +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt similarity index 66% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt index 2a736bf38a..c5bde44947 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt @@ -1,19 +1,16 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings -import android.content.res.Configuration import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.tooling.preview.Preview -import chat.simplex.app.R -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.chatlist.ChatHelpView -import chat.simplex.app.views.helpers.AppBarTitle +import androidx.compose.desktop.ui.tooling.preview.Preview +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.chatlist.ChatHelpView +import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.res.MR @Composable @@ -34,12 +31,11 @@ fun HelpLayout(userDisplayName: String) { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewHelpView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 8c266b8975..215899d0ff 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionItemView @@ -15,13 +15,12 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.ChatModel -import chat.simplex.app.model.User -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chatlist.UserProfileRow -import chat.simplex.app.views.database.PassphraseField -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.User +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.UserProfileRow +import chat.simplex.common.views.database.PassphraseField +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt similarity index 81% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt index b7aeb0a802..4728c0f2b7 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/IncognitoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import androidx.compose.foundation.* @@ -8,10 +8,9 @@ import androidx.compose.runtime.* 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.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.helpers.AppBarTitle -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt similarity index 84% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt index aa405ef15c..ab2df2014c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt @@ -1,23 +1,19 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer -import android.content.res.Configuration import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import chat.simplex.app.R -import chat.simplex.app.model.Format -import chat.simplex.app.model.FormatColor -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.common.model.Format +import chat.simplex.common.model.FormatColor +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.res.MR @Composable @@ -87,12 +83,11 @@ fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: St b.append(after) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewMarkdownHelpView() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 72292d80ba..344d002aa0 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionCustomFooter @@ -20,14 +20,15 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.* import androidx.compose.ui.text.input.* -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.desktop.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.item.ClickableText -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ClickableText +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.views.helpers.annotatedStringResource import chat.simplex.res.MR @Composable @@ -434,7 +435,7 @@ private fun showUpdateNetworkSettingsDialog( ) } -@Preview(showBackground = true) +@Preview @Composable fun PreviewNetworkAndServersLayout() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt similarity index 68% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 054686b0de..6c59bb1508 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -1,9 +1,8 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionView import SectionViewSelectable -import android.os.Build import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -14,33 +13,12 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.capitalize 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.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.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; - - companion object { - val default: NotificationPreviewMode = MESSAGE - } -} - @Composable fun NotificationsSettingsView( chatModel: ChatModel, @@ -51,14 +29,14 @@ fun NotificationsSettingsView( } NotificationsSettingsLayout( - notificationsMode = chatModel.notificationsMode, + notificationsMode = remember { chatModel.controller.appPrefs.notificationsMode.state }, notificationPreviewMode = chatModel.notificationPreviewMode, showPage = { page -> ModalManager.shared.showModalCloseable(true) { - when (page) { - CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.notificationsMode) { changeNotificationsMode(it, chatModel) } - CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected) - } + when (page) { + CurrentPage.NOTIFICATIONS_MODE -> NotificationsModeView(chatModel.controller.appPrefs.notificationsMode.state) { changeNotificationsMode(it, chatModel) } + CurrentPage.NOTIFICATION_PREVIEW_MODE -> NotificationPreviewView(chatModel.notificationPreviewMode, onNotificationPreviewModeSelected) + } } }, ) @@ -82,13 +60,15 @@ fun NotificationsSettingsLayout( ) { AppBarTitle(stringResource(MR.strings.notifications)) SectionView(null) { - SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), { showPage(CurrentPage.NOTIFICATIONS_MODE) }) { - Text( - modes.first { it.value == notificationsMode.value }.title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colors.secondary - ) + if (appPlatform == AppPlatform.ANDROID) { + SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), { showPage(CurrentPage.NOTIFICATIONS_MODE) }) { + Text( + modes.first { it.value == notificationsMode.value }.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.secondary + ) + } } SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notification_preview_mode_title), { showPage(CurrentPage.NOTIFICATION_PREVIEW_MODE) }) { Text( @@ -186,21 +166,6 @@ fun notificationPreviewModes(): List> { } fun changeNotificationsMode(mode: NotificationsMode, chatModel: ChatModel) { - chatModel.controller.appPrefs.notificationsMode.set(mode.name) - if (mode.requiresIgnoringBattery && !SimplexService.isIgnoringBatteryOptimizations()) { - chatModel.controller.appPrefs.backgroundServiceNoticeShown.set(false) - } - chatModel.notificationsMode.value = mode - SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) - CoroutineScope(Dispatchers.Default).launch { - if (mode == NotificationsMode.SERVICE) - SimplexService.start() - else - SimplexService.safeStopService(SimplexApp.context) - } - - if (mode != NotificationsMode.PERIODIC) { - MessagesFetcherWorker.cancelAll() - } - SimplexService.showBackgroundServiceNoticeIfNeeded() + chatModel.controller.appPrefs.notificationsMode.set(mode) + platform.androidNotificationsModeChanged(mode) } diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt similarity index 97% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index c6da840f2f..91a1a2ec86 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced @@ -13,9 +13,8 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.R -import chat.simplex.app.model.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 899c4cd516..0b00df1c43 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -1,11 +1,10 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.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.* @@ -13,7 +12,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -21,19 +19,19 @@ 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 -import kotlinx.coroutines.runBlocking +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.ProfileNameField +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword +import chat.simplex.common.views.helpers.DatabaseUtils.ksSelfDestructPassword +import chat.simplex.common.views.isValidDisplayName +import chat.simplex.common.views.localauth.SetAppPasscodeView +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.AppPlatform +import chat.simplex.common.platform.appPlatform enum class LAMode { SYSTEM, @@ -44,6 +42,11 @@ enum class LAMode { SYSTEM -> generalGetString(MR.strings.la_mode_system) PASSCODE -> generalGetString(MR.strings.la_mode_passcode) } + + companion object { + val default: LAMode + get() = if (appPlatform == AppPlatform.ANDROID) SYSTEM else PASSCODE + } } @Composable @@ -57,20 +60,7 @@ fun PrivacySettingsView( ) { 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) - } - } - } + PrivacyDeviceSection(showSettingsModal, setPerformLA) SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_chats)) { @@ -155,6 +145,12 @@ private fun SimpleXLinkOptions(simplexLinkModeState: State, onS ) } +@Composable +expect fun PrivacyDeviceSection( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + setPerformLA: (Boolean) -> Unit, +) + @Composable private fun DeliveryReceiptsSection( currentUser: User, @@ -231,7 +227,6 @@ fun SimplexLockView( 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 } @@ -243,7 +238,7 @@ fun SimplexLockView( fun disableUnavailableLA() { resetLAEnabled(false) - currentLAMode.set(LAMode.SYSTEM) + currentLAMode.set(LAMode.default) laUnavailableInstructionAlert() } @@ -406,12 +401,14 @@ fun SimplexLockView( setPerformLA(false) } } - LockModeSelector(laMode) { newLAMode -> - if (laMode.value == newLAMode) return@LockModeSelector - if (chatModel.controller.appPrefs.performLA.get()) { - toggleLAMode(newLAMode) - } else { - currentLAMode.set(newLAMode) + if (appPlatform == AppPlatform.ANDROID) { + LockModeSelector(laMode) { newLAMode -> + if (laMode.value == newLAMode) return@LockModeSelector + if (chatModel.controller.appPrefs.performLA.get()) { + toggleLAMode(newLAMode) + } else { + currentLAMode.set(newLAMode) + } } } @@ -584,3 +581,30 @@ private fun passcodeAlert(title: String) { 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/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt similarity index 93% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ProtocolServerView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt index a711275ccf..f3896a3de9 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServerView.kt @@ -1,11 +1,11 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween import SectionView -import android.util.Log +import chat.simplex.common.platform.Log import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer @@ -20,15 +20,14 @@ import androidx.compose.ui.text.TextStyle 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.TAG -import chat.simplex.app.model.* -import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCode +import chat.simplex.common.platform.TAG +import chat.simplex.common.model.* +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.model.ChatModel import chat.simplex.res.MR -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.isActive import kotlinx.coroutines.launch diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt similarity index 93% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ProtocolServersView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index b7e3e2a202..a6ee09f749 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced @@ -19,11 +19,11 @@ import androidx.compose.ui.text.* 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.model.* -import chat.simplex.app.model.ServerAddress.Companion.parseServerAddress -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.views.usersettings.ScanProtocolServer import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -114,18 +114,20 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () }) { Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - SectionItemView({ - AlertManager.shared.hideAlert() - ModalManager.shared.showModalCloseable { close -> - ScanProtocolServer { - close() - servers = servers + it - m.userSMPServersUnsaved.value = servers + if (appPlatform.isAndroid) { + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.shared.showModalCloseable { close -> + ScanProtocolServer { + close() + servers = servers + it + m.userSMPServersUnsaved.value = servers + } } } - } - ) { - Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + ) { + Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } val hasAllPresets = hasAllPresets(presetServers, servers, m) if (!hasAllPresets) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/RTCServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/RTCServers.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt index f79b968b15..7cb30440d3 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/RTCServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionItemViewSpaceBetween @@ -18,11 +18,10 @@ import androidx.compose.ui.text.TextStyle 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.model.ChatModel -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.call.parseRTCIceServers -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.parseRTCIceServers +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt similarity index 54% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt index 69f1e31435..02582ec93c 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.kt @@ -1,32 +1,22 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.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.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.model.ServerCfg +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.res.MR @Composable -fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { - val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) - LaunchedEffect(Unit) { - cameraPermissionState.launchPermissionRequest() - } - ScanProtocolServerLayout(onNext) -} +expect fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) @Composable -private fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { +fun ScanProtocolServerLayout(onNext: (ServerCfg) -> Unit) { Column( Modifier .fillMaxSize() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt similarity index 93% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SetDeliveryReceiptsView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index 04fe102ddd..1189c8bba6 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -1,7 +1,6 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer -import android.util.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -12,11 +11,12 @@ import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.style.TextAlign -import chat.simplex.app.TAG -import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.helpers.* +import chat.simplex.common.model.ChatModel import chat.simplex.res.MR +import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.Log +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* @Composable fun SetDeliveryReceiptsView(m: ChatModel) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt similarity index 89% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index b033f69f38..3226dc85be 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced @@ -6,7 +6,7 @@ import SectionItemView import SectionItemViewWithIcon import SectionView import TextIconSpaced -import android.content.res.Configuration +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -21,20 +21,15 @@ 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.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.common.model.* +import chat.simplex.common.platform.appVersionInfo +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.database.DatabaseView +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.SimpleXInfo +import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.res.MR -import com.jakewharton.processphoenix.ProcessPhoenix @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { @@ -61,7 +56,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { ModalView( { close() }, endButtons = { - SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = true) { search.value = it } + SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } }, content = { modalView(chatModel, search) }) } @@ -180,17 +175,20 @@ fun SettingsLayout( } 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) - } + SettingsSectionApp(showSettingsModal, showCustomModal, showVersion, withAuth) SectionBottomSpacer() } } } +@Composable +expect fun SettingsSectionApp( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), + showVersion: () -> Unit, + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit +) + @Composable fun SettingsIncognitoActionItem( incognitoPref: SharedPreference, @@ -273,14 +271,13 @@ fun MaintainIncognitoState(chatModel: ChatModel) { @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 } + val performLA = remember { ChatModel.performLA } + val currentLAMode = remember { ChatModel.controller.appPrefs.laMode } SettingsActionItemWithContent( - click = showSettingsModal { SimplexLockView(chatModel, currentLAMode, setPerformLA) }, + 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, @@ -354,12 +351,13 @@ fun ChatLockItem( } } -@Composable private fun AppVersionItem(showVersion: () -> Unit) { +@Composable +fun AppVersionItem(showVersion: () -> Unit) { SectionItemViewWithIcon(showVersion) { AppVersionText() } } @Composable fun AppVersionText() { - Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else "")) } @Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) { @@ -490,32 +488,11 @@ private fun runAuth(title: String, desc: String, onFinish: (success: Boolean) -> ) } -private fun restartApp() { - ProcessPhoenix.triggerRebirth(SimplexApp.context) - shutdownApp() -} - -private fun shutdownApp() { - WorkManager.getInstance(SimplexApp.context).cancelAllWork() - SimplexService.safeStopService(SimplexApp.context) - Runtime.getRuntime().exit(0) -} - -private fun shutdownAppAlert(onConfirm: () -> Unit) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.shutdown_alert_question), - text = generalGetString(MR.strings.shutdown_alert_desc), - destructive = true, - onConfirm = onConfirm - ) -} - -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewSettingsLayout() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt similarity index 77% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressLearnMore.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt index de08ffda66..276c595435 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt @@ -1,14 +1,13 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.R -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.onboarding.ReadableText -import chat.simplex.app.views.onboarding.ReadableTextWithLink +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.res.MR @Composable diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt similarity index 95% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 2cd8484296..d907e0fff5 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -1,12 +1,11 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView -import android.content.res.Configuration -import android.util.Log +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -16,17 +15,19 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler 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.ui.theme.* -import chat.simplex.app.views.chat.ShareAddressButton -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.QRCode +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.ShareAddressButton +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.* import chat.simplex.res.MR @Composable @@ -56,6 +57,8 @@ fun UserAddressView( } } val userAddress = remember { chatModel.userAddress } + val clipboard = LocalClipboardManager.current + val uriHandler = LocalUriHandler.current val showLayout = @Composable { UserAddressLayout( userAddress = userAddress.value, @@ -93,9 +96,9 @@ fun UserAddressView( } } }, - share = { userAddress: String -> shareText(userAddress) }, + share = { userAddress: String -> clipboard.shareText(userAddress) }, sendEmail = { userAddress -> - sendEmail( + uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), generalGetString(MR.strings.email_invite_body).format(userAddress.connReqContact) ) @@ -416,12 +419,11 @@ private fun SaveAASButton(disabled: Boolean, onClick: () -> Unit) { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewUserAddressLayoutNoAddress() { SimpleXTheme { @@ -450,12 +452,11 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { ) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewUserAddressLayoutAddressCreated() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt similarity index 91% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index ee03a689ec..7f8edbd0fd 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -1,8 +1,7 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer -import android.content.res.Configuration -import android.net.Uri +import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -16,20 +15,19 @@ import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.painterResource 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 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.ProfileNameField -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.isValidDisplayName -import chat.simplex.app.views.onboarding.ReadableText -import com.google.accompanist.insets.ProvideWindowInsets -import com.google.accompanist.insets.navigationBarsWithImePadding +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.ProfileNameField +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.isValidDisplayName +import chat.simplex.common.views.onboarding.ReadableText +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.Profile +import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.coroutines.launch +import java.net.URI @Composable fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { @@ -62,7 +60,7 @@ fun UserProfileLayout( val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val displayName = remember { mutableStateOf(profile.displayName) } val fullName = remember { mutableStateOf(profile.fullName) } - val chosenImage = rememberSaveable { mutableStateOf(null) } + val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(profile.image) } val scope = rememberCoroutineScope() val scrollState = rememberScrollState() @@ -217,12 +215,11 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { ) } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewUserProfileLayoutEditOff() { SimpleXTheme { @@ -234,12 +231,11 @@ fun PreviewUserProfileLayoutEditOff() { } } -@Preview(showBackground = true) -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewUserProfileLayoutEditOn() { SimpleXTheme { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt similarity index 96% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index 49024f5977..b788787854 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDivider @@ -20,16 +20,16 @@ import androidx.compose.ui.text.* 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.ui.theme.* -import chat.simplex.app.views.chat.item.ItemAction -import chat.simplex.app.views.chatlist.UserProfilePickerItem -import chat.simplex.app.views.chatlist.UserProfileRow -import chat.simplex.app.views.database.PassphraseField -import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.onboarding.CreateProfile +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatPasswordHash +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.UserProfilePickerItem +import chat.simplex.common.views.chatlist.UserProfileRow +import chat.simplex.common.views.database.PassphraseField +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.CreateProfile +import chat.simplex.common.model.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt similarity index 55% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/VersionInfoView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index e7eab47993..8528414771 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -1,17 +1,16 @@ -package chat.simplex.app.views.usersettings +package chat.simplex.common.views.usersettings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.app.BuildConfig -import chat.simplex.app.R -import chat.simplex.app.model.CoreVersionInfo -import chat.simplex.app.ui.theme.DEFAULT_PADDING -import chat.simplex.app.views.helpers.AppBarTitle +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.CoreVersionInfo +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.res.MR @Composable @@ -20,8 +19,12 @@ fun VersionInfoView(info: CoreVersionInfo) { Modifier.padding(horizontal = DEFAULT_PADDING), ) { AppBarTitle(stringResource(MR.strings.app_version_title), false) - Text(String.format(stringResource(MR.strings.app_version_name), BuildConfig.VERSION_NAME)) - Text(String.format(stringResource(MR.strings.app_version_code), BuildConfig.VERSION_CODE)) + if (appPlatform.isAndroid) { + Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.ANDROID_VERSION_NAME)) + Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) + } else { + Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME)) + } Text(String.format(stringResource(MR.strings.core_version), info.version)) val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit Text(String.format(stringResource(MR.strings.core_simplexmq_version), info.simplexmqVersion, simplexmqCommit)) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index f3fd6f128b..32e8c4ce88 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -394,6 +394,7 @@ Camera From Gallery File + Choose a file Image Video diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt new file mode 100644 index 0000000000..4c5cfb2526 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -0,0 +1,149 @@ +package chat.simplex.common + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.* +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.* +import chat.simplex.common.model.ChatController +import chat.simplex.common.model.ChatModel +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.helpers.FileDialogChooser +import kotlinx.coroutines.* +import java.awt.event.WindowEvent +import java.awt.event.WindowFocusListener +import java.io.File + +val simplexWindowState = SimplexWindowState() + +fun showApp() = application { + val windowState = rememberWindowState(placement = WindowPlacement.Floating) + Window(state = windowState, onCloseRequest = ::exitApplication, onKeyEvent = { + if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) { + simplexWindowState.backstack.lastOrNull()?.invoke() != null + } else { + false + } + }, title = "SimpleX") { + SimpleXTheme { + AppScreen() + if (simplexWindowState.openDialog.isAwaiting) { + FileDialogChooser( + title = "SimpleX", + isLoad = true, + params = simplexWindowState.openDialog.params, + onResult = { + simplexWindowState.openDialog.onResult(it.firstOrNull()) + } + ) + } + + if (simplexWindowState.openMultipleDialog.isAwaiting) { + FileDialogChooser( + title = "SimpleX", + isLoad = true, + params = simplexWindowState.openMultipleDialog.params, + onResult = { + simplexWindowState.openMultipleDialog.onResult(it) + } + ) + } + + if (simplexWindowState.saveDialog.isAwaiting) { + FileDialogChooser( + title = "SimpleX", + isLoad = false, + params = simplexWindowState.saveDialog.params, + onResult = { simplexWindowState.saveDialog.onResult(it.firstOrNull()) } + ) + } + val toasts = remember { simplexWindowState.toasts } + val toast = toasts.firstOrNull() + if (toast != null) { + Box(Modifier.fillMaxSize().padding(bottom = 20.dp), contentAlignment = Alignment.BottomCenter) { + Text( + toast.first, + Modifier.background(MaterialTheme.colors.primary, RoundedCornerShape(100)).padding(vertical = 5.dp, horizontal = 10.dp), + color = MaterialTheme.colors.onPrimary, + style = MaterialTheme.typography.body1 + ) + } + // Shows toast in insertion order with preferred delay per toast. New one will be shown once previous one expires + LaunchedEffect(toast, toasts.size) { + delay(toast.second) + simplexWindowState.toasts.removeFirst() + } + } + } + var windowFocused by remember { mutableStateOf(true) } + LaunchedEffect(windowFocused) { + val delay = ChatController.appPrefs.laLockDelay.get() + if (!windowFocused && ChatModel.performLA.value && delay > 0) { + delay(delay * 1000L) + // Trigger auth state check when delay ends (and if it ends) + AppLock.recheckAuthState() + } + } + LaunchedEffect(Unit) { + window.addWindowFocusListener(object : WindowFocusListener { + override fun windowGainedFocus(p0: WindowEvent?) { + windowFocused = true + AppLock.recheckAuthState() + } + + override fun windowLostFocus(p0: WindowEvent?) { + windowFocused = false + AppLock.appWasHidden() + } + }) + } + } +} + +class SimplexWindowState { + val backstack = mutableStateListOf<() -> Unit>() + val openDialog = DialogState() + val openMultipleDialog = DialogState>() + val saveDialog = DialogState() + val toasts = mutableStateListOf>() +} + +data class DialogParams( + val allowMultiple: Boolean = false, + val fileFilter: ((File?) -> Boolean)? = null, + val fileFilterDescription: String = "", +) + +class DialogState { + private var onResult: CompletableDeferred? by mutableStateOf(null) + var params = DialogParams() + val isAwaiting get() = onResult != null + + suspend fun awaitResult(params: DialogParams = DialogParams()): T { + onResult = CompletableDeferred() + this.params = params + val result = onResult!!.await() + onResult = null + return result + } + + fun onResult(result: T) = onResult!!.complete(result) +} + +@Preview +@Composable +fun AppPreview() { + SimpleXTheme { + AppScreen() + } +} + +/** Needed for [chat.simplex.common.platform.Files] to get path to jar file */ +class DesktopApp() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt new file mode 100644 index 0000000000..7c9201e4e1 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -0,0 +1,67 @@ +package chat.simplex.common.platform + +import chat.simplex.common.DesktopApp +import chat.simplex.common.model.* +import chat.simplex.common.views.call.RcvCallInvitation +import chat.simplex.common.views.helpers.withBGApi +import java.io.* +import java.nio.file.* +import java.nio.file.attribute.BasicFileAttributes +import java.util.* + +actual val appPlatform = AppPlatform.DESKTOP + +@Suppress("ConstantLocale") +val defaultLocale: Locale = Locale.getDefault() + +fun initApp() { + ntfManager = object : NtfManager() { // LALAL + override fun notifyContactConnected(user: User, contact: Contact) {} + override fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {} + override fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {} + override fun notifyCallInvitation(invitation: RcvCallInvitation) {} + override fun hasNotificationsForChat(chatId: String): Boolean = false + override fun cancelNotificationsForChat(chatId: String) {} + override fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String?, actions: List) {} + override fun createNtfChannelsMaybeShowAlert() {} + override fun cancelCallNotification() {} + override fun cancelAllNotifications() {} + } + applyAppLocale() + withBGApi { + initChatController() + runMigrations() + } +} + +private fun applyAppLocale() { + val lang = ChatController.appPrefs.appLanguage.get() + if (lang == null || lang == Locale.getDefault().language) return + Locale.setDefault(Locale.forLanguageTag(lang)) +} + +@Suppress("UnsafeDynamicallyLoadedCode") +actual fun initHaskell() { + val libApp = "libapp-lib.${desktopPlatform.libExtension}" + val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") + copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) + System.load(File(libsTmpDir, libApp).absolutePath) + libsTmpDir.deleteRecursively() + initHS() +} + +private fun copyResources(from: String, to: Path) { + val resource = DesktopApp::class.java.getResource("")!!.toURI() + val fileSystem = FileSystems.newFileSystem(resource, emptyMap()) + val resPath = fileSystem.getPath(from) + Files.walkFileTree(resPath, object: SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.createDirectories(to.resolve(resPath.relativize(dir).toString())) + return FileVisitResult.CONTINUE + } + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.copy(file, to.resolve(resPath.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING) + return FileVisitResult.CONTINUE + } + }) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Back.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Back.desktop.kt new file mode 100644 index 0000000000..7c64cd469a --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Back.desktop.kt @@ -0,0 +1,14 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* +import chat.simplex.common.simplexWindowState + +@Composable +actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) { + DisposableEffect(enabled) { + if (enabled) simplexWindowState.backstack.add(onBack) + onDispose { + simplexWindowState.backstack.remove(onBack) + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt new file mode 100644 index 0000000000..870fde945b --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt @@ -0,0 +1,15 @@ +package chat.simplex.common.platform + +actual val cryptor: CryptorInterface = object : CryptorInterface { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? { + return String(data) // LALAL + } + + override fun encryptText(text: String, alias: String): Pair { + return text.toByteArray() to text.toByteArray() // LALAL + } + + override fun deleteKey(alias: String) { + // LALAL + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt new file mode 100644 index 0000000000..023683fece --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -0,0 +1,95 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* +import chat.simplex.common.* +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import java.io.* +import java.net.URI + +private fun applicationParentPath(): String = try { + DesktopApp::class.java.protectionDomain!!.codeSource.location.toURI().path + .replaceAfterLast("/", "") + .replaceAfterLast(File.separator, "") + .replace("/", File.separator) +} catch (e: Exception) { + "./" +} + +actual val dataDir: File = File(desktopPlatform.dataPath) +actual val tmpDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex").also { it.deleteOnExit() } +actual val filesDir: File = File(dataDir.absolutePath + File.separator + "simplex_v1_files") +actual val appFilesDir: File = filesDir +actual val coreTmpDir: File = File(dataDir.absolutePath + File.separator + "tmp") +actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "simplex_v1" + +actual val chatDatabaseFileName: String = "simplex_v1_chat.db" +actual val agentDatabaseFileName: String = "simplex_v1_agent.db" + +actual val databaseExportDir: File = tmpDir + +@Composable +actual fun rememberFileChooserLauncher(getContent: Boolean, onResult: (URI?) -> Unit): FileChooserLauncher = + remember { FileChooserLauncher(getContent, onResult) } + +@Composable +actual fun rememberFileChooserMultipleLauncher(onResult: (List) -> Unit): FileChooserMultipleLauncher = + remember { FileChooserMultipleLauncher(onResult) } + +actual class FileChooserLauncher actual constructor() { + var getContent: Boolean = false + lateinit var onResult: (URI?) -> Unit + + constructor(getContent: Boolean, onResult: (URI?) -> Unit): this() { + this.getContent = getContent + this.onResult = onResult + } + + actual suspend fun launch(input: String) { + val res = if (getContent) { + val params = DialogParams( + allowMultiple = false, + fileFilter = fileFilter(input), + fileFilterDescription = fileFilterDescription(input), + ) + simplexWindowState.openDialog.awaitResult(params) + } else { + simplexWindowState.saveDialog.awaitResult() + } + onResult(res?.toURI()) + } +} + +actual class FileChooserMultipleLauncher actual constructor() { + lateinit var onResult: (List) -> Unit + + constructor(onResult: (List) -> Unit): this() { + this.onResult = onResult + } + + actual suspend fun launch(input: String) { + val params = DialogParams( + allowMultiple = true, + fileFilter = fileFilter(input), + fileFilterDescription = fileFilterDescription(input), + ) + onResult(simplexWindowState.openMultipleDialog.awaitResult(params).map { it.toURI() }) + } +} + +private fun fileFilter(input: String): (File?) -> Boolean = when(input) { + "image/*" -> { file -> if (file?.isDirectory == true) true else if (file != null) isImage(file.toURI()) else false } + "video/*" -> { file -> if (file?.isDirectory == true) true else if (file != null) isVideo(file.toURI()) else false } + "*/*" -> { _ -> true } + else -> { _ -> true } +} + +private fun fileFilterDescription(input: String): String = when(input) { + "image/*" -> generalGetString(MR.strings.gallery_image_button) + "video/*" -> generalGetString(MR.strings.gallery_video_button) + "*/*" -> generalGetString(MR.strings.choose_file) + else -> "" +} + +actual fun URI.inputStream(): InputStream? = File(URI("file:" + toString().removePrefix("file:"))).inputStream() +actual fun URI.outputStream(): OutputStream = File(URI("file:" + toString().removePrefix("file:"))).outputStream() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt new file mode 100644 index 0000000000..81699a6cc1 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -0,0 +1,142 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.graphics.* +import boofcv.io.image.ConvertBufferedImage +import boofcv.struct.image.GrayU8 +import chat.simplex.res.MR +import org.jetbrains.skia.Image +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.* +import java.net.URI +import java.util.* +import javax.imageio.ImageIO +import kotlin.math.sqrt + +private fun errorBitmap(): ImageBitmap = + ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap() + +actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { + val imageString = base64ImageString + .removePrefix("data:image/png;base64,") + .removePrefix("data:image/jpg;base64,") + return try { + ImageIO.read(ByteArrayInputStream(Base64.getDecoder().decode(imageString))).toComposeImageBitmap() + } catch (e: IOException) { + Log.e(TAG, "base64ToBitmap error: $e") + errorBitmap() + } +} + +actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { + var img = image + var str = compressImageStr(img) + while (str.length > maxDataSize) { + val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) + val clippedRatio = kotlin.math.min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = img.scale(width, height) + str = compressImageStr(img) + } + return str +} +actual fun resizeImageToDataSize(image: ImageBitmap, 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 = kotlin.math.min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = img.scale(width, height) + stream = compressImageData(img, usePng) + } + return stream +} + +actual fun cropToSquare(image: ImageBitmap): ImageBitmap { + var xOffset = 0 + var yOffset = 0 + val side = kotlin.math.min(image.height, image.width) + if (image.height < image.width) { + xOffset = (image.width - side) / 2 + } else { + yOffset = (image.height - side) / 2 + } + // LALAL MAKE REAL CROP + return image +} + +actual fun compressImageStr(bitmap: ImageBitmap): String { + val usePng = bitmap.hasAlpha + val ext = if (usePng) "png" else "jpg" + return try { + val encoded = Base64.getEncoder().encodeToString(compressImageData(bitmap, usePng).toByteArray()) + "data:image/$ext;base64,$encoded" + } catch (e: IOException) { + Log.e(TAG, "resizeImageToStrSize error: $e") + throw e + } +} + +actual fun compressImageData(bitmap: ImageBitmap, usePng: Boolean): ByteArrayOutputStream { + val stream = ByteArrayOutputStream() + stream.use { s -> ImageIO.write(bitmap.toAwtImage(), if (usePng) "png" else "jpg", s) } + // MAKE REAL COMPRESSION + //bitmap.compress(if (!usePng) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, 85, stream) + return stream +} + +actual fun GrayU8.toImageBitmap(): ImageBitmap = ConvertBufferedImage.extractBuffered(this).toComposeImageBitmap() + +actual fun ImageBitmap.addLogo(): ImageBitmap { + val radius = (width * 0.16f).toInt() + val logoSize = (width * 0.24).toInt() + val logo: BufferedImage = MR.images.icon_foreground_common.image + val original = toAwtImage() + val withLogo = BufferedImage(width, height, original.type) + val g = withLogo.createGraphics() + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR + ) + g.drawImage(original, 0, 0, width, height, 0, 0, original.width, original.height, null) + g.fillRoundRect(width / 2 - radius / 2, height / 2 - radius / 2, radius, radius, radius, radius) + g.drawImage(logo, (width - logoSize) / 2, (height - logoSize) / 2, logoSize, logoSize, null) + g.dispose() + + return withLogo.toComposeImageBitmap() +} + +actual fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap { + val original = toAwtImage() + val resized = BufferedImage(width, height, original.type) + val g = resized.createGraphics() + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR + ) + g.drawImage(original, 0, 0, width, height, 0, 0, original.width, original.height, null) + g.dispose() + return resized.toComposeImageBitmap() +} + +// LALAL +actual fun isImage(uri: URI): Boolean { + val path = uri.path.lowercase() + return path.endsWith(".gif") || + path.endsWith(".webp") || + path.endsWith(".png") || + path.endsWith(".jpg") || + path.endsWith(".jpeg") +} + +actual fun isAnimImage(uri: URI, drawable: Any?): Boolean { + val path = uri.path.lowercase() + return path.endsWith(".gif") || path.endsWith(".webp") +} + +@Suppress("NewApi") +actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = + Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Log.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Log.desktop.kt new file mode 100644 index 0000000000..395754c51b --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Log.desktop.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.platform + +actual object Log { + actual fun d(tag: String, text: String) = println("D: $text") + actual fun e(tag: String, text: String) = println("E: $text") + actual fun i(tag: String, text: String) = println("I: $text") + actual fun w(tag: String, text: String) = println("W: $text") +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt new file mode 100644 index 0000000000..72e163c2ed --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -0,0 +1,15 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +actual fun Modifier.navigationBarsWithImePadding(): Modifier = this + +@Composable +actual fun ProvideWindowInsets( + consumeWindowInsets: Boolean, + windowInsetsAnimationsEnabled: Boolean, + content: @Composable () -> Unit +) { + content() +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt new file mode 100644 index 0000000000..40f89d17f1 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt @@ -0,0 +1,5 @@ +package chat.simplex.common.platform + +import chat.simplex.common.model.NotificationsMode + +actual fun allowedToShowNotification(): Boolean = true diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt new file mode 100644 index 0000000000..0a49ed5beb --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -0,0 +1,30 @@ +package chat.simplex.common.platform + +import java.io.File +import java.util.* + +private val home = System.getProperty("user.home") +private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config") + "/simplex" +private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex" +val desktopPlatform = detectDesktopPlatform() + +enum class DesktopPlatform(val libPath: String, val libExtension: String, val configPath: String, val dataPath: String) { + LINUX_X86_64("/libs/linux-x86_64", "so", unixConfigPath, unixDataPath), + LINUX_AARCH64("/libs/aarch64", "so", unixConfigPath, unixDataPath), + WINDOWS_X86_64("/libs/windows-x86_64", "dll", System.getenv("AppData") + File.separator + "SimpleX", System.getenv("AppData") + File.separator + "SimpleX"), + MAC_X86_64("/libs/mac-x86_64", "dylib", unixConfigPath, unixDataPath), + MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath); +} + +private fun detectDesktopPlatform(): DesktopPlatform { + val os = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) + val arch = System.getProperty("os.arch") + return when { + os == "linux" && (arch.contains("x86") || arch == "amd64") -> DesktopPlatform.LINUX_X86_64 + os == "linux" && arch == "aarch64" -> DesktopPlatform.LINUX_AARCH64 + os.contains("windows") && (arch.contains("x86") || arch == "amd64") -> DesktopPlatform.WINDOWS_X86_64 + os.contains("mac") && arch.contains("x86") -> DesktopPlatform.MAC_X86_64 + os.contains("mac") && arch.contains("aarch64") -> DesktopPlatform.MAC_AARCH64 + else -> TODO("Currently, your processor's architecture ($arch) or os ($os) are unsupported. Please, contact us") + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt new file mode 100644 index 0000000000..7148f8217e --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -0,0 +1,107 @@ +package chat.simplex.common.platform + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.StringResource +import kotlinx.coroutines.delay +import kotlin.math.min + +@Composable +actual fun PlatformTextField( + composeState: MutableState, + textStyle: MutableState, + showDeleteTextButton: MutableState, + userIsObserver: Boolean, + onMessageChange: (String) -> Unit +) { + val cs = composeState.value + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp) + LaunchedEffect(cs.contextItem) { + if (cs.contextItem !is ComposeContextItem.QuotedItem) return@LaunchedEffect + // In replying state + focusRequester.requestFocus() + delay(50) + keyboard?.show() + } + val isRtl = remember(cs.message) { isRtl(cs.message.subSequence(0, min(50, cs.message.length))) } + BasicTextField( + value = cs.message, + onValueChange = { + if (!composeState.value.inProgress && !(composeState.value.preview is ComposePreview.VoicePreview && it != "")) { + onMessageChange(it) + } + }, + textStyle = textStyle.value, + maxLines = 16, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + autoCorrect = true + ), + modifier = Modifier.padding(vertical = 4.dp).focusRequester(focusRequester), + cursorBrush = SolidColor(MaterialTheme.colors.secondary), + decorationBox = { innerTextField -> + Surface( + shape = RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, MaterialTheme.colors.secondary) + ) { + Row( + Modifier.background(MaterialTheme.colors.background), + verticalAlignment = Alignment.Bottom + ) { + CompositionLocalProvider( + LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + ) { + Box( + Modifier + .weight(1f) + .padding(horizontal = 12.dp) + .padding(top = 4.dp) + .padding(bottom = 6.dp) + ) { + innerTextField() + } + } + } + } + } + ) + showDeleteTextButton.value = cs.message.split("\n").size >= 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/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt new file mode 100644 index 0000000000..7e2ab9d5d0 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -0,0 +1,53 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.MutableState +import chat.simplex.common.model.ChatItem +import kotlinx.coroutines.CoroutineScope + +actual class RecorderNative: RecorderInterface { + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { + /*LALAL*/ + return "" + } + + override fun stop(): Int { + /*LALAL*/ + return 0 + } +} + +actual object AudioPlayer: AudioPlayerInterface { + override fun play(filePath: String?, audioPlaying: MutableState, progress: MutableState, duration: MutableState, resetOnEnd: Boolean) { + /*LALAL*/ + } + + override fun stop() { + /*LALAL*/ + } + + override fun stop(item: ChatItem) { + /*LALAL*/ + } + + override fun stop(fileName: String?) { + TODO("Not yet implemented") + } + + override fun pause(audioPlaying: MutableState, pro: MutableState) { + TODO("Not yet implemented") + } + + override fun seekTo(ms: Int, pro: MutableState, filePath: String?) { + /*LALAL*/ + } + + override fun duration(filePath: String): Int? { + /*LALAL*/ + return null + } +} + +actual object SoundPlayer: SoundPlayerInterface { + override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } + override fun stop() { /*LALAL*/ } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt new file mode 100644 index 0000000000..1bedcb6f44 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -0,0 +1,58 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.russhwolf.settings.* +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.desc.desc +import java.io.File +import java.util.* + +@Composable +actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font = + androidx.compose.ui.text.platform.Font("MR/fonts/$res.ttf", weight, style) + +actual fun StringResource.localized(): String = desc().toString() + +actual fun isInNightMode() = false + +private val settingsFile = + File(desktopPlatform.configPath + File.separator + "settings.properties") + .also { it.parentFile.mkdirs() } +private val settingsThemesFile = + File(desktopPlatform.configPath + File.separator + "themes.properties") + .also { it.parentFile.mkdirs() } +private val settingsProps = + Properties() + .also { try { it.load(settingsFile.reader()) } catch (e: Exception) { Properties() } } +private val settingsThemesProps = + Properties() + .also { try { it.load(settingsThemesFile.reader()) } catch (e: Exception) { Properties() } } + +actual val settings: Settings = PropertiesSettings(settingsProps) { settingsProps.store(settingsFile.writer(), "") } +actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) { settingsThemesProps.store(settingsThemesFile.writer(), "") } + +actual fun screenOrientation(): ScreenOrientation = ScreenOrientation.UNDEFINED + +@Composable // LALAL +actual fun screenWidth(): Dp { + return java.awt.Toolkit.getDefaultToolkit().screenSize.width.dp.also { println("LALAL $it") } + /*var width by remember { mutableStateOf(java.awt.Toolkit.getDefaultToolkit().screenSize.width.also { println("LALAL $it") }) } + SideEffect { + if (width != java.awt.Toolkit.getDefaultToolkit().screenSize.width) + width = java.awt.Toolkit.getDefaultToolkit().screenSize.width + } + return width*/ +}// LALAL java.awt.Desktop.getDesktop() + +actual fun isRtl(text: CharSequence): Boolean { + if (text.isEmpty()) return false + return text.any { char -> + val dir = Character.getDirectionality(char) + dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT || dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt new file mode 100644 index 0000000000..84e24a1d55 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -0,0 +1,31 @@ +package chat.simplex.common.platform + +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString +import chat.simplex.common.views.helpers.withApi +import java.io.File +import java.net.URI +import java.net.URLEncoder +import chat.simplex.res.MR + +actual fun UriHandler.sendEmail(subject: String, body: CharSequence) { + val subjectEncoded = URLEncoder.encode(subject, "UTF-8").replace("+", "%20") + val bodyEncoded = URLEncoder.encode(subject, "UTF-8").replace("+", "%20") + openUri("mailto:?subject=$subjectEncoded&body=$bodyEncoded") +} + +actual fun ClipboardManager.shareText(text: String) { + setText(AnnotatedString(text)) + showToast(MR.strings.copied.localized()) +} + +actual fun shareFile(text: String, filePath: String) { + withApi { + FileChooserLauncher(false) { to: URI? -> + if (to != null) { + copyFileToFile(File(filePath), to) {} + } + }.launch(filePath) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt new file mode 100644 index 0000000000..1ea5018fcc --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt @@ -0,0 +1,19 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* +import chat.simplex.common.simplexWindowState +import chat.simplex.common.views.helpers.KeyboardState + +actual fun showToast(text: String, timeout: Long) { + simplexWindowState.toasts.add(text to timeout) +} + +@Composable +actual fun LockToCurrentOrientationUntilDispose() {} + +@Composable +actual fun LocalMultiplatformView(): Any? = null + +@Composable +actual fun getKeyboardState(): State = remember { mutableStateOf(KeyboardState.Opened) } +actual fun hideKeyboard(view: Any?) {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt new file mode 100644 index 0000000000..0c40c1c350 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -0,0 +1,50 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.MutableState +import androidx.compose.ui.graphics.ImageBitmap +import java.net.URI + +actual class VideoPlayer: VideoPlayerInterface { + actual companion object { + actual fun getOrCreate( + uri: URI, + gallery: Boolean, + defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean + ): VideoPlayer = VideoPlayer() + actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean { TODO() } + actual fun release(uri: URI, gallery: Boolean, remove: Boolean) { TODO() } + actual fun stopAll() { /*LALAL*/ } + actual fun releaseAll() { /*LALAL*/ } + } + + override val soundEnabled: MutableState + get() = TODO("Not yet implemented") + override val brokenVideo: MutableState + get() = TODO("Not yet implemented") + override val videoPlaying: MutableState + get() = TODO("Not yet implemented") + override val progress: MutableState + get() = TODO("Not yet implemented") + override val duration: MutableState + get() = TODO("Not yet implemented") + override val preview: MutableState + get() = TODO("Not yet implemented") + + override fun stop() { + TODO("Not yet implemented") + } + + override fun play(resetOnEnd: Boolean) { + TODO("Not yet implemented") + } + + override fun enableSound(enable: Boolean): Boolean { + TODO("Not yet implemented") + } + + override fun release(remove: Boolean) { + TODO("Not yet implemented") + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt new file mode 100644 index 0000000000..54a511e082 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt @@ -0,0 +1,13 @@ +package chat.simplex.common.platform + +import java.net.URI + +fun isVideo(uri: URI): Boolean { + val path = uri.path.lowercase() + return path.endsWith(".mov") || + path.endsWith(".avi") || + path.endsWith(".mp4") || + path.endsWith(".mpg") || + path.endsWith(".mpeg") || + path.endsWith(".mkv") +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt new file mode 100644 index 0000000000..9026752366 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt @@ -0,0 +1,14 @@ +package chat.simplex.common.ui.theme + +import androidx.compose.ui.text.font.* +import androidx.compose.ui.text.platform.Font +import chat.simplex.res.MR + +actual val Inter: FontFamily = FontFamily( + Font(MR.fonts.Inter.regular.file), + Font(MR.fonts.Inter.italic.file, style = FontStyle.Italic), + Font(MR.fonts.Inter.bold.file, FontWeight.Bold), + Font(MR.fonts.Inter.semibold.file, FontWeight.SemiBold), + Font(MR.fonts.Inter.medium.file, FontWeight.Medium), + Font(MR.fonts.Inter.light.file, FontWeight.Light) +) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt new file mode 100644 index 0000000000..df45a8acb0 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.views.call + +import androidx.compose.runtime.Composable + +@Composable +actual fun ActiveCallView() { + // LALAL +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeView.desktop.kt new file mode 100644 index 0000000000..dd9f3dd612 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeView.desktop.kt @@ -0,0 +1,40 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.* +import java.net.URI + +@Composable +actual fun AttachmentSelection( + composeState: MutableState, + attachmentOption: MutableState, + processPickedFile: (URI?, String?) -> Unit, + processPickedMedia: (List, String?) -> Unit +) { + val imageLauncher = rememberFileChooserMultipleLauncher { + processPickedMedia(it, null) + } + val videoLauncher = rememberFileChooserMultipleLauncher { + processPickedMedia(it, null) + } + val filesLauncher = rememberFileChooserLauncher(true) { + if (it != null) processPickedFile(it, null) + } + LaunchedEffect(attachmentOption.value) { + when (attachmentOption.value) { + AttachmentOption.CameraPhoto -> {} + AttachmentOption.GalleryImage -> { + imageLauncher.launch("image/*") + } + AttachmentOption.GalleryVideo -> { + videoLauncher.launch("video/*") + } + AttachmentOption.File -> { + filesLauncher.launch("*/*") + } + else -> {} + } + attachmentOption.value = null + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt new file mode 100644 index 0000000000..7ea2ef5368 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.desktop.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.Composable + +@Composable +actual fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) { + ScanCodeLayout(verifyCode, close) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/SendMsgView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/SendMsgView.desktop.kt new file mode 100644 index 0000000000..05ff1d73dc --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/SendMsgView.desktop.kt @@ -0,0 +1,11 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.Composable + +@Composable +actual fun allowedToRecordVoiceByPlatform(): Boolean = true + +@Composable +actual fun VoiceButtonWithoutPermissionByPlatform() { + VoiceButtonWithoutPermission {} +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt new file mode 100644 index 0000000000..a9c7f5f0ae --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -0,0 +1,28 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.CIFile +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.ModalManager +import java.net.URI + +@Composable +actual fun SimpleAndAnimatedImageView( + uri: URI, + imageBitmap: ImageBitmap, + file: CIFile?, + imageProvider: () -> ImageGalleryProvider, + ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit +) { + // LALAL make it animated too + ImageView(imageBitmap.toAwtImage().toPainter()) { + if (getLoadedFilePath(file) != null) { + ModalManager.shared.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt new file mode 100644 index 0000000000..aac995e480 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -0,0 +1,23 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import chat.simplex.common.platform.VideoPlayer + +@Composable +actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { + /* LALAL */ +} + +@Composable +actual fun LocalWindowWidth(): Dp { + return with(LocalDensity.current) { (java.awt.Window.getWindows().find { it.isActive }?.width ?: 0).toDp() } + /*val density = LocalDensity.current + var width by remember { mutableStateOf(with(density) { (java.awt.Window.getWindows().find { it.isActive }?.width ?: 0).toDp() }) } + SideEffect { + if (width != with(density) { (java.awt.Window.getWindows().find { it.isActive }?.width ?: 0).toDp() }) + width = with(density) { (java.awt.Window.getWindows().find { it.isActive }?.width ?: 0).toDp() } + } + return width.also { println("LALAL $it") }*/ +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt new file mode 100644 index 0000000000..6f4878cfe3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -0,0 +1,23 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.FileChooserLauncher +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { + ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + when (cItem.content.msgContent) { + is MsgContent.MCImage -> saveImage(getAppFileUri(cItem.file?.fileName ?: return@ItemAction)) + is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } + else -> {} + } + showMenu.value = false + }) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt new file mode 100644 index 0000000000..e4e483092d --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -0,0 +1,28 @@ +package chat.simplex.common.views.chat.item + +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.layout.ContentScale +import chat.simplex.common.platform.VideoPlayer +import chat.simplex.common.views.helpers.getBitmapFromUri +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.net.URI + +@Composable +actual fun FullScreenImageView(modifier: Modifier, uri: URI, imageBitmap: ImageBitmap) { + Image( + getBitmapFromUri(uri, false) ?: MR.images.decentralized.image.toComposeImageBitmap(), + contentDescription = stringResource(MR.strings.image_descr), + contentScale = ContentScale.Fit, + modifier = modifier, + ) +} +@Composable +actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) { + +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt new file mode 100644 index 0000000000..32f5a0a80c --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt @@ -0,0 +1,22 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import chat.simplex.common.views.newchat.ActionButton +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun ChooseAttachmentButtons(attachmentOption: MutableState, hide: () -> Unit) { + ActionButton(Modifier.fillMaxWidth(0.5f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { + attachmentOption.value = AttachmentOption.GalleryImage + hide() + } + ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { + attachmentOption.value = AttachmentOption.File + hide() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt new file mode 100644 index 0000000000..58edc0d88b --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -0,0 +1,141 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.runtime.* +import androidx.compose.ui.input.key.* +import androidx.compose.ui.window.* +import chat.simplex.common.DialogParams +import chat.simplex.res.MR +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.awt.FileDialog +import java.io.File +import java.util.* +import javax.swing.JFileChooser +import javax.swing.filechooser.FileFilter +import javax.swing.filechooser.FileNameExtensionFilter + +@Composable +actual fun DefaultDialog( + onDismissRequest: () -> Unit, + content: @Composable () -> Unit +) { + Dialog( + undecorated = true, + title = "", + onCloseRequest = onDismissRequest, + onPreviewKeyEvent = { event -> + if (event.key == Key.Escape && event.type == KeyEventType.KeyUp) { + onDismissRequest(); true + } else false + } + ) { + content() + } +} + +@Composable +fun FrameWindowScope.FileDialogChooser( + title: String, + isLoad: Boolean, + params: DialogParams, + onResult: (result: List) -> Unit +) { + if (isLinux()) { + FileDialogChooserMultiple(title, isLoad, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult) + } else { + FileDialogAwt(title, isLoad, params.allowMultiple, params.fileFilter, onResult) + } +} + +@Composable +fun FrameWindowScope.FileDialogChooserMultiple( + title: String, + isLoad: Boolean, + allowMultiple: Boolean, + fileFilter: ((File?) -> Boolean)? = null, + fileFilterDescription: String? = null, + onResult: (result: List) -> Unit +) { + val scope = rememberCoroutineScope() + DisposableEffect(Unit) { + val job = scope.launch(Dispatchers.Main) { + val fileChooser = JFileChooser() + fileChooser.dialogTitle = title + fileChooser.isMultiSelectionEnabled = allowMultiple && isLoad + fileChooser.isAcceptAllFileFilterUsed = fileFilter == null + if (fileFilter != null && fileFilterDescription != null) { + fileChooser.addChoosableFileFilter(object: FileFilter() { + override fun accept(file: File?): Boolean = fileFilter(file) + + override fun getDescription(): String = fileFilterDescription + }) + } + val returned = if (isLoad) { + fileChooser.showOpenDialog(window) + } else { + fileChooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + fileChooser.showSaveDialog(window) + } + val result = when (returned) { + JFileChooser.APPROVE_OPTION -> { + if (isLoad) { + when { + allowMultiple -> fileChooser.selectedFiles.filter { it.canRead() } + fileChooser.selectedFile != null && fileChooser.selectedFile.canRead() -> listOf(fileChooser.selectedFile) + else -> emptyList() + } + } else { + if (!fileChooser.fileFilter.accept(fileChooser.selectedFile)) { + val ext = (fileChooser.fileFilter as FileNameExtensionFilter).extensions[0] + fileChooser.selectedFile = File(fileChooser.selectedFile.absolutePath + ".$ext") + } + listOf(fileChooser.selectedFile) + } + } + else -> emptyList() + } + onResult(result) + } + onDispose { + job.cancel() + } + } +} + +/* +* Has graphic glitches on many Linux distributions, so use only on non-Linux systems +* */ +@Composable +private fun FrameWindowScope.FileDialogAwt( + title: String, + isLoad: Boolean, + allowMultiple: Boolean, + fileFilter: ((File?) -> Boolean)? = null, + onResult: (result: List) -> Unit +) = AwtWindow( + create = { + object: FileDialog(window, generalGetString(MR.strings.choose_file_title), if (isLoad) LOAD else SAVE) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + if (value) { + if (files != null) { + onResult(files.toList()) + } else { + onResult(emptyList()) + } + } + } + }.apply { + this.title = title + this.isMultipleMode = allowMultiple && isLoad + if (fileFilter != null) { + this.setFilenameFilter { dir, file -> + fileFilter(File(dir.absolutePath + File.separator + file)) + } + } + } + }, + dispose = FileDialog::dispose +) + +fun isLinux(): Boolean = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) == "linux" diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt new file mode 100644 index 0000000000..993025a115 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt @@ -0,0 +1,43 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.DropdownMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import kotlin.math.exp + +actual interface DefaultExposedDropdownMenuBoxScope { + @Composable + actual fun DefaultExposedDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier, + content: @Composable ColumnScope.() -> Unit + ) { + DropdownMenu(expanded, onDismissRequest, offset = DpOffset(0.dp, (-40).dp)) { + Column { + content() + } + } + } +} + +@Composable +actual fun DefaultExposedDropdownMenuBox( + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier, + content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit +) { + val obj = remember { object : DefaultExposedDropdownMenuBoxScope {} } + Box(Modifier + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { onExpandedChange(!expanded) }) + ) { + obj.content() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt new file mode 100644 index 0000000000..544c15abe8 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt @@ -0,0 +1,52 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.ImageBitmap +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.rememberFileChooserLauncher +import chat.simplex.res.MR +import chat.simplex.common.views.newchat.ActionButton +import java.net.URI + +@Composable +actual fun GetImageBottomSheet( + imageBitmap: MutableState, + onImageChange: (ImageBitmap) -> Unit, + hideBottomSheet: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .onFocusChanged { focusState -> + if (!focusState.hasFocus) hideBottomSheet() + } + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 30.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + val processPickedImage = { uri: URI? -> + if (uri != null) { + val bitmap = getBitmapFromUri(uri) + if (bitmap != null) { + imageBitmap.value = uri + onImageChange(bitmap) + } + hideBottomSheet() + } + } + val pickImageLauncher = rememberFileChooserLauncher(true, processPickedImage) + ActionButton(null, stringResource(MR.strings.from_gallery_button), icon = painterResource(MR.images.ic_image)) { + withApi { pickImageLauncher.launch("image/*") } + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.desktop.kt new file mode 100644 index 0000000000..a251b7dc20 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.desktop.kt @@ -0,0 +1,16 @@ +package chat.simplex.common.views.helpers + +import chat.simplex.common.views.usersettings.LAMode + +actual fun authenticate( + promptTitle: String, + promptSubtitle: String, + selfDestruct: Boolean, + usingLAMode: LAMode, + completed: (LAResult) -> Unit +) { + when (usingLAMode) { + LAMode.PASSCODE -> authenticateWithPasscode(promptTitle, promptSubtitle, selfDestruct, completed) + else -> {} + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt new file mode 100644 index 0000000000..47d7829749 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -0,0 +1,72 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.ui.graphics.* +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Density +import chat.simplex.common.model.CIFile +import chat.simplex.common.platform.* +import chat.simplex.common.simplexWindowState +import java.io.File +import java.net.URI +import java.util.* +import javax.imageio.ImageIO +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.io.path.toPath + +// LALAL MAKE REALLY ANNOTATED STRING FROM HTML +actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { + return AnnotatedString(text) +} + +actual fun getAppFileUri(fileName: String): URI = + URI("file:" + appFilesDir.absolutePath + File.separator + fileName) + +actual fun getLoadedImage(file: CIFile?): ImageBitmap? { + val filePath = getLoadedFilePath(file) + return if (filePath != null) { + val uri = getAppFileUri(filePath.substringAfterLast(File.separator)) + getBitmapFromUri(uri, false) + } else { + null + } +} + +actual fun getFileName(uri: URI): String? = uri.toPath().toFile().name + +actual fun getAppFilePath(uri: URI): String? = uri.path + +actual fun getFileSize(uri: URI): Long? = uri.toPath().toFile().length() + +actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? = + ImageIO.read(uri.inputStream()).toComposeImageBitmap() + +// LALAL implement to support animated drawable +actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? = null + +actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File? { + val file = simplexWindowState.saveDialog.awaitResult() + return if (file != null) { + try { + val ext = if (asPng) "png" else "jpg" + val newFile = File(file.absolutePath + File.separator + generateNewFileName("IMG", ext)) + // LALAL FILE IS EMPTY + ImageIO.write(image.toAwtImage(), ext.uppercase(), newFile.outputStream()) + newFile + } catch (e: Exception) { + Log.e(TAG, "Util.kt saveTempImageUncompressed error: ${e.message}") + null + } + } else null +} + +actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { + // LALAL + return VideoPlayerInterface.PreviewAndDuration(preview = null, timestamp = 0L, duration = 0L) +} + +@OptIn(ExperimentalEncodingApi::class) +actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encode(this) + +@OptIn(ExperimentalEncodingApi::class) +actual fun String.toByteArrayFromBase64ForPassphrase(): ByteArray = Base64.decode(this) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt new file mode 100644 index 0000000000..84da0a7757 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ConnectViaLinkView.desktop.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.runtime.* +import chat.simplex.common.model.ChatModel + +@Composable +actual fun ConnectViaLinkView(m: ChatModel, close: () -> Unit) { + PasteToConnectView(m, close) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCode.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCode.desktop.kt new file mode 100644 index 0000000000..ae5cd24ce7 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCode.desktop.kt @@ -0,0 +1,18 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.ui.graphics.* + +actual fun ImageBitmap.replaceColor(from: Int, to: Int): ImageBitmap { + val image = toAwtImage() + val pixels = IntArray(width * height) + image.getRGB(0, 0, width, height, pixels, 0, image.width) + var i = 0 + while (i < pixels.size) { + if (pixels[i] == from) { + pixels[i] = to + } + i++ + } + image.setRGB(0, 0, width, height, pixels, 0, image.width) + return image.toComposeImageBitmap() +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.desktop.kt new file mode 100644 index 0000000000..16d35b5b8d --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.desktop.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.runtime.* + +@Composable +actual fun QRCodeScanner(onBarcode: (String) -> Unit) { + //LALAL +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt new file mode 100644 index 0000000000..66f2d5c6d2 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt @@ -0,0 +1,12 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.runtime.Composable +import chat.simplex.common.model.ChatModel + +@Composable +actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { + ConnectContactLayout( + chatModelIncognito = chatModel.incognito.value, + close = close + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.desktop.kt new file mode 100644 index 0000000000..f770de904a --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.desktop.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.views.onboarding + +import androidx.compose.runtime.Composable + +@Composable +actual fun SetNotificationsModeAdditions() {} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt new file mode 100644 index 0000000000..851cf137dd --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -0,0 +1,70 @@ +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.SharedPreference +import chat.simplex.common.platform.defaultLocale +import chat.simplex.common.ui.theme.ThemeColor +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.AppearanceScope.ColorEditor +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import java.util.Locale + +@Composable +actual fun AppearanceView(m: ChatModel, showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit)) { + AppearanceScope.AppearanceLayout( + m.controller.appPrefs.appLanguage, + m.controller.appPrefs.systemDarkTheme, + showSettingsModal = showSettingsModal, + editColor = { name, initialColor -> + ModalManager.shared.showModalCloseable { close -> + ColorEditor(name, initialColor, close) + } + }, + ) +} + +@Composable +fun AppearanceScope.AppearanceLayout( + languagePref: SharedPreference, + systemDarkTheme: SharedPreference, + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + editColor: (ThemeColor, Color) -> Unit, +) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) { + AppBarTitle(stringResource(MR.strings.appearance_settings)) + SectionView(stringResource(MR.strings.settings_section_title_language), padding = PaddingValues()) { + val state = rememberSaveable { mutableStateOf(languagePref.get() ?: "system") } + LangSelector(state) { + state.value = it + withApi { + delay(200) + if (it == "system") { + languagePref.set(null) + Locale.setDefault(defaultLocale) + } else { + languagePref.set(it) + Locale.setDefault(Locale.forLanguageTag(it)) + } + } + } + } + SectionDividerSpaced(maxTopPadding = true) + ThemesSection(systemDarkTheme, showSettingsModal, editColor) + SectionBottomSpacer() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt new file mode 100644 index 0000000000..a51fd20c5b --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt @@ -0,0 +1,17 @@ +package chat.simplex.common.views.usersettings + +import SectionView +import androidx.compose.runtime.Composable +import chat.simplex.common.model.ChatModel +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun PrivacyDeviceSection( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + setPerformLA: (Boolean) -> Unit, +) { + SectionView(stringResource(MR.strings.settings_section_title_device)) { + ChatLockItem(showSettingsModal, setPerformLA) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt new file mode 100644 index 0000000000..464c286316 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/ScanProtocolServer.desktop.kt @@ -0,0 +1,9 @@ +package chat.simplex.common.views.usersettings + +import androidx.compose.runtime.Composable +import chat.simplex.common.model.ServerCfg + +@Composable +actual fun ScanProtocolServer(onNext: (ServerCfg) -> Unit) { + ScanProtocolServerLayout(onNext) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt new file mode 100644 index 0000000000..95a079fe1b --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -0,0 +1,21 @@ +package chat.simplex.common.views.usersettings + +import SectionView +import androidx.compose.runtime.Composable +import chat.simplex.common.model.ChatModel +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +actual fun SettingsSectionApp( + showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), + showVersion: () -> Unit, + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit +) { + SectionView(stringResource(MR.strings.settings_section_title_app)) { + SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) }, extraPadding = true) + AppVersionItem(showVersion) + } +} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/Main.kt new file mode 100644 index 0000000000..3b53d83a7a --- /dev/null +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/Main.kt @@ -0,0 +1,9 @@ +import chat.simplex.common.platform.* +import chat.simplex.common.showApp + +fun main() { + initHaskell() + initApp() + tmpDir.deleteRecursively() + return showApp() +} diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh new file mode 100755 index 0000000000..0e19b5a925 --- /dev/null +++ b/scripts/desktop/build-lib-linux.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +OS=linux +ARCH=${1:-`uname -a | rev | cut -d' ' -f2 | rev`} +GHC_VERSION=8.10.7 + +BUILD_DIR=dist-newstyle/build/$ARCH-$OS/ghc-${GHC_VERSION}/simplex-chat-* + +rm -rf $BUILD_DIR +cabal build lib:simplex-chat --ghc-options='-optl-Wl,-rpath,$ORIGIN' --ghc-options="-optl-L$(ghc --print-libdir)/rts -optl-Wl,--as-needed,-lHSrts_thr-ghc$GHC_VERSION" +cd $BUILD_DIR/build +#patchelf --add-needed libHSrts_thr-ghc${GHC_VERSION}.so libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so +#patchelf --add-rpath '$ORIGIN' libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so +mkdir deps +ldd libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so | grep "ghc" | cut -d' ' -f 3 | xargs -I {} cp {} ./deps/ + +cd - + +rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/common/src/commonMain/resources/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/desktop/build/cmake + +mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh new file mode 100755 index 0000000000..59c0aeb127 --- /dev/null +++ b/scripts/desktop/build-lib-mac.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +OS=mac +ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" +if [ "$ARCH" == "arm64" ]; then + ARCH=aarch64 +fi +LIB_EXT=dylib +LIB=libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT +GHC_LIBS_DIR=$(ghc --print-libdir) + +BUILD_DIR=dist-newstyle/build/$ARCH-*/ghc-*/simplex-chat-* + +rm -rf $BUILD_DIR +cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/rts -optl-lHSrts_thr-ghc8.10.7 -optl-lffi" + +cd $BUILD_DIR/build +mkdir deps 2> /dev/null + +# It's not included by default for some reason. Compiled lib tries to find system one but it's not always available +cp $GHC_LIBS_DIR/rts/libffi.dylib ./deps + +DYLIBS=`otool -L $LIB | grep @rpath | tail -n +2 | cut -d' ' -f 1 | cut -d'/' -f2` +RPATHS=`otool -l $LIB | grep "path "| cut -d' ' -f11` + +PROCESSED_LIBS=() + +function copy_deps() { + local LIB=$1 + if [[ "${PROCESSED_LIBS[*]}" =~ "$LIB" ]]; then + return 0 + fi + + PROCESSED_LIBS+=$LIB + local DYLIBS=`otool -L $LIB | grep @rpath | tail -n +2 | cut -d' ' -f 1 | cut -d'/' -f2` + local NON_FINAL_RPATHS=`otool -l $LIB | grep "path "| cut -d' ' -f11` + local RPATHS=`otool -l $LIB | grep "path "| cut -d' ' -f11 | sed "s|@loader_path/..|$GHC_LIBS_DIR|"` + + cp $LIB ./deps + if [[ "$NON_FINAL_RPATHS" == *"@loader_path/.."* ]]; then + # Need to point the lib to @loader_path instead + install_name_tool -add_rpath @loader_path ./deps/`basename $LIB` + fi + #echo LIB $LIB + #echo DYLIBS ${DYLIBS[@]} + #echo RPATHS ${RPATHS[@]} + + for DYLIB in $DYLIBS; do + for RPATH in $RPATHS; do + if [ -f "$RPATH/$DYLIB" ]; then + #echo DEP IS "$RPATH/$DYLIB" + if [ ! -f "deps/$DYLIB" ]; then + cp "$RPATH/$DYLIB" ./deps + fi + copy_deps "$RPATH/$DYLIB" + fi + done + done +} + +copy_deps $LIB +rm deps/`basename $LIB` + +cd - + +rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/common/src/commonMain/resources/libs/$OS-$ARCH/ +rm -rf apps/multiplatform/desktop/build/cmake + +mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/