diff --git a/README.md b/README.md index 904f1dd2b8..a6cb204193 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ You can use SimpleX with your own servers and still communicate with people usin Recent updates: +[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md). + [May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md). [Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md). @@ -337,8 +339,8 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A - ✅ Message reactions - ✅ Message editing history - ✅ Reduced battery and traffic usage in large groups. +- ✅ Message delivery confirmation (with sender opt-out per contact). - 🏗 Desktop client. -- 🏗 Message delivery confirmation (with sender opt-in or opt-out per contact, TBC). - SMP queue redundancy and rotation (manual is supported). - Include optional message into connection request sent via contact address. - Local app files encryption. diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index cb908cbab3..2a5735990a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -137,11 +137,11 @@ 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CE2BA96284537A800EC33A6 /* dummy.m */; }; 5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5CE2BAA62845617C00EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; platformFilter = ios; }; - 5CE381D72A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381D22A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI.a */; }; - 5CE381D82A69AE88004FB9E1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381D32A69AE88004FB9E1 /* libgmp.a */; }; - 5CE381D92A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381D42A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI-ghc8.10.7.a */; }; - 5CE381DA2A69AE88004FB9E1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381D52A69AE88004FB9E1 /* libffi.a */; }; - 5CE381DB2A69AE88004FB9E1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381D62A69AE88004FB9E1 /* libgmpxx.a */; }; + 5CE381E12A6C103D004FB9E1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DC2A6C103D004FB9E1 /* libffi.a */; }; + 5CE381E22A6C103D004FB9E1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DD2A6C103D004FB9E1 /* libgmpxx.a */; }; + 5CE381E32A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DE2A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a */; }; + 5CE381E42A6C103D004FB9E1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381DF2A6C103D004FB9E1 /* libgmp.a */; }; + 5CE381E52A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE381E02A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; @@ -415,11 +415,11 @@ 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = SimpleXChat.docc; sourceTree = ""; }; 5CE2BA8A2845332200EC33A6 /* SimpleX.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SimpleX.h; sourceTree = ""; }; 5CE2BA96284537A800EC33A6 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; - 5CE381D22A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI.a"; sourceTree = ""; }; - 5CE381D32A69AE88004FB9E1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CE381D42A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI-ghc8.10.7.a"; sourceTree = ""; }; - 5CE381D52A69AE88004FB9E1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CE381D62A69AE88004FB9E1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CE381DC2A6C103D004FB9E1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CE381DD2A6C103D004FB9E1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CE381DE2A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a"; sourceTree = ""; }; + 5CE381DF2A6C103D004FB9E1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CE381E02A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a"; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; @@ -501,13 +501,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CE381E22A6C103D004FB9E1 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CE381DA2A69AE88004FB9E1 /* libffi.a in Frameworks */, + 5CE381E32A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a in Frameworks */, + 5CE381E52A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a in Frameworks */, + 5CE381E12A6C103D004FB9E1 /* libffi.a in Frameworks */, + 5CE381E42A6C103D004FB9E1 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CE381D82A69AE88004FB9E1 /* libgmp.a in Frameworks */, - 5CE381D72A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI.a in Frameworks */, - 5CE381D92A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI-ghc8.10.7.a in Frameworks */, - 5CE381DB2A69AE88004FB9E1 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -568,11 +568,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CE381D52A69AE88004FB9E1 /* libffi.a */, - 5CE381D32A69AE88004FB9E1 /* libgmp.a */, - 5CE381D62A69AE88004FB9E1 /* libgmpxx.a */, - 5CE381D42A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI-ghc8.10.7.a */, - 5CE381D22A69AE88004FB9E1 /* libHSsimplex-chat-5.2.0.3-9DDzk5dukPe6oTQ1CZlGPI.a */, + 5CE381DC2A6C103D004FB9E1 /* libffi.a */, + 5CE381DF2A6C103D004FB9E1 /* libgmp.a */, + 5CE381DD2A6C103D004FB9E1 /* libgmpxx.a */, + 5CE381DE2A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd-ghc8.10.7.a */, + 5CE381E02A6C103D004FB9E1 /* libHSsimplex-chat-5.2.0.4-HUgQMHMGu1K8FDeUwC5hCd.a */, ); path = Libraries; sourceTree = ""; @@ -1478,7 +1478,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 159; + CURRENT_PROJECT_VERSION = 160; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1520,7 +1520,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 159; + CURRENT_PROJECT_VERSION = 160; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1600,7 +1600,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 159; + CURRENT_PROJECT_VERSION = 160; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1632,7 +1632,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 159; + CURRENT_PROJECT_VERSION = 160; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1664,7 +1664,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 159; + CURRENT_PROJECT_VERSION = 160; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1710,7 +1710,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 159; + CURRENT_PROJECT_VERSION = 160; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index ea16af7157..344359b38c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -127,7 +127,7 @@ public enum ChatCommand { case let .setAllContactReceipts(enable): return "/set receipts all \(onOff(enable))" case let .apiSetUserContactReceipts(userId, userMsgReceiptSettings): let umrs = userMsgReceiptSettings - return "/_set receipts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" + return "/_set receipts contacts \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" case let .apiMuteUser(userId): return "/_mute user \(userId)" 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/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 092f87690b..91c41e562a 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,54 +56,45 @@ 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 { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.1") + implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") } } val desktopTest 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..226e97d4dc --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Resources.android.kt @@ -0,0 +1,53 @@ +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 desktopExpandWindowToWidth(width: 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/Theme.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt new file mode 100644 index 0000000000..0dcbb42322 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt @@ -0,0 +1,6 @@ +package chat.simplex.common.ui.theme + +import androidx.compose.runtime.Composable + +@Composable +actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme() 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..dc8e9dd541 --- /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.fullscreen.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..e02c011fac --- /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.start.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..dda00fc43c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -0,0 +1,332 @@ +package chat.simplex.common + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +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.* +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.* +import chat.simplex.common.views.database.DatabaseErrorView +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.localauth.VerticalDivider +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.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +data class SettingsViewState( + val userPickerState: MutableStateFlow, + val scaffoldState: ScaffoldState, + val switchingUsers: MutableState +) + +@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.closeAllModalsEverywhere() + 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 + val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } + val scaffoldState = rememberScaffoldState() + val switchingUsers = rememberSaveable { mutableStateOf(false) } + val settingsState = remember { SettingsViewState(userPickerState, scaffoldState, switchingUsers) } + if (appPlatform.isAndroid) { + AndroidScreen(settingsState) + } else { + DesktopScreen(settingsState) + } + } + } + onboarding == OnboardingStage.Step1_SimpleXInfo -> { + SimpleXInfo(chatModel, onboarding = true) + if (appPlatform.isDesktop) { + ModalManager.fullscreen.showInView() + } + } + onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} + onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) + onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) + } + if (appPlatform.isAndroid) { + ModalManager.fullscreen.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.fullscreen.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 AndroidScreen(settingsState: SettingsViewState) { + 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() + } + ) { + StartPartOfScreen(settingsState) + } + 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) + } + } + } +} + +@Composable +fun StartPartOfScreen(settingsState: SettingsViewState) { + if (chatModel.setDeliveryReceipts.value) { + SetDeliveryReceiptsView(chatModel) + } else { + val stopped = chatModel.chatRunning.value == false + if (chatModel.sharedContent.value == null) + ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped) + else + ShareListView(chatModel, settingsState, stopped) + } +} + +@Composable +fun CenterPartOfScreen() { + val currentChatId by remember { ChatModel.chatId } + LaunchedEffect(Unit) { + snapshotFlow { currentChatId } + .distinctUntilChanged() + .collect { + if (it != null) { + ModalManager.center.closeModals() + } + } + } + when (val id = currentChatId) { + null -> { + if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background), + contentAlignment = Alignment.Center + ) { + Text(stringResource(MR.strings.no_selected_chat)) + } + } else { + ModalManager.center.showInView() + } + } + else -> ChatView(id, chatModel) {} + } +} + +@Composable +fun EndPartOfScreen() { + ModalManager.end.showInView() +} + +@Composable +fun DesktopScreen(settingsState: SettingsViewState) { + Box { + // 56.dp is a size of unused space of settings drawer + Box(Modifier.width(DEFAULT_START_MODAL_WIDTH + 56.dp)) { + StartPartOfScreen(settingsState) + } + Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) { + ModalManager.start.showInView() + } + Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) { + Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) { + CenterPartOfScreen() + } + if (ModalManager.end.hasModalsOpen()) { + VerticalDivider() + } + Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH).clipToBounds()) { + EndPartOfScreen() + } + } + val (userPickerState, scaffoldState, switchingUsers ) = settingsState + val scope = rememberCoroutineScope() + if (scaffoldState.drawerState.isOpen) { + Box( + Modifier + .fillMaxSize() + .padding(start = DEFAULT_START_MODAL_WIDTH) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { + ModalManager.start.closeModals() + scope.launch { settingsState.scaffoldState.drawerState.close() } + }) + ) + } + VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH)) + UserPicker(chatModel, userPickerState, switchingUsers) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + } + ModalManager.fullscreen.showInView() + ModalManager.fullscreen.showPasscodeInView() + } +} + +@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..fdc1969411 --- /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.default) + 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.fullscreen.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..3189e1c705 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 @@ -430,7 +437,7 @@ object ChatModel { val info = getChat(id)?.chatInfo as? ChatInfo.ContactConnection ?: return if (info.contactConnection.connReqInv == connReqInv.value) { connReqInv.value = null - ModalManager.shared.closeModals() + ModalManager.center.closeModals() } } @@ -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..0e2f53a581 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) @@ -74,7 +72,7 @@ class AppPreferences { set = fun(action: CallOnLockScreen) { _callOnLockScreen.set(action.name) } ) val performLA = mkBoolPreference(SHARED_PREFS_PERFORM_LA, false) - val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.SYSTEM) { LAMode.values().firstOrNull { it.name == this } } + val laMode = mkEnumPreference(SHARED_PREFS_LA_MODE, LAMode.default) { LAMode.values().firstOrNull { it.name == this } } val laLockDelay = mkIntPreference(SHARED_PREFS_LA_LOCK_DELAY, 30) val laNoticeShown = mkBoolPreference(SHARED_PREFS_LA_NOTICE_SHOWN, false) val webrtcIceServers = mkStrPreference(SHARED_PREFS_WEBRTC_ICE_SERVERS, null) @@ -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( @@ -1875,7 +1882,7 @@ sealed class CC { is SetAllContactReceipts -> "/set receipts all ${onOff(enable)}" is ApiSetUserContactReceipts -> { val mrs = userMsgReceiptSettings - "/_set receipts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" + "/_set receipts contacts $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" } is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}" @@ -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/other/wheel-picker/FWheelPickerDefault.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/wheel-picker/FWheelPickerDefault.kt index ed2533b488..9760e9c9f2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/wheel-picker/FWheelPickerDefault.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/other/wheel-picker/FWheelPickerDefault.kt @@ -2,7 +2,7 @@ package com.sd.lib.compose.wheel_picker import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme +import chat.simplex.common.ui.theme.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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..ed35392159 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -0,0 +1,50 @@ +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 + + val isDesktop: Boolean + get() = this == DESKTOP +} + +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..c7799e592b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Resources.kt @@ -0,0 +1,33 @@ +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 desktopExpandWindowToWidth(width: 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 92% 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..6af3156ca2 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,22 +1,19 @@ -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.* import androidx.compose.runtime.* 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; @@ -193,6 +190,11 @@ val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2 val DEFAULT_BOTTOM_PADDING = 48.dp val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp +val DEFAULT_START_MODAL_WIDTH = 388.dp +val DEFAULT_MIN_CENTER_MODAL_WIDTH = 590.dp +val DEFAULT_END_MODAL_WIDTH = 388.dp +val DEFAULT_MAX_IMAGE_WIDTH = 500.dp + val DarkColorPalette = darkColors( primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primaryVariant = SimplexBlue, @@ -254,13 +256,18 @@ 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 +expect fun isSystemInDarkTheme(): Boolean + +fun reactOnDarkThemeChanges(isDark: Boolean) { + if (ChatController.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == isDark) { + // Change active colors from light to dark and back based on system theme + ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, isDark) + } +} + @Composable fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { LaunchedEffect(darkTheme) { @@ -270,10 +277,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) { - // Change active colors from light to dark and back based on system theme - ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark) - } + reactOnDarkThemeChanges(systemDark) } val theme by CurrentColors.collectAsState() MaterialTheme( 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 85% 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..47bb6918dd 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,32 +8,37 @@ 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) { val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) } + val close = { + close() + if (appPlatform.isDesktop) { + ModalManager.center.closeModals() + } + } 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 +121,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 +132,7 @@ fun TerminalLog(terminalItems: List) { modifier = Modifier .fillMaxWidth() .clickable { - ModalManager.shared.showModal(endButtons = { ShareButton { shareText(item.details) } }) { + ModalManager.start.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 +143,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..85ab5c2ea3 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.* @@ -83,7 +81,7 @@ fun ChatInfoView( setContactAlias(chat.chatInfo.apiId, it, chatModel) }, openPreferences = { - ModalManager.shared.showCustomModal { close -> + ModalManager.end.showCustomModal { close -> val user = chatModel.currentUser.value if (user != null) { ContactPreferencesView(chatModel, user, contact.contactId, close) @@ -138,7 +136,7 @@ fun ChatInfoView( }) }, verifyClicked = { - ModalManager.shared.showModalCloseable { close -> + ModalManager.end.showModalCloseable { close -> remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct -> VerifyCodeView( ct.displayName, @@ -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 95% 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..84a9d02bfd 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, @@ -144,7 +139,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { if (chat.chatInfo is ChatInfo.Direct) { val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId) val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId) - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct -> ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) } @@ -154,7 +150,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId) var groupLink = link?.first var groupLinkMemberRole = link?.second - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { close -> GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, { groupLink = it.first; groupLinkMemberRole = it.second @@ -179,7 +176,8 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { member to null } setGroupMembers(groupInfo, chatModel) - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) } @@ -319,8 +317,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { withApi { 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())) + ModalManager.end.closeModals() + ModalManager.end.showModal(endButtons = { ShareButton { + clipboard.shareText(itemInfoShareText(cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) } }) { ChatItemInfoView(cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) } @@ -331,14 +330,15 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { hideKeyboard(view) withApi { setGroupMembers(groupInfo, chatModel) - ModalManager.shared.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, false, chatModel, close) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { close -> + AddGroupMembersView(groupInfo, false, chatModel, close) } } }, 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, @@ -467,11 +467,13 @@ fun ChatInfoToolbar( showSearch = false } } - BackHandler(onBack = onBackClicked) + if (appPlatform.isAndroid) { + BackHandler(onBack = onBackClicked) + } 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 }) @@ -525,7 +527,7 @@ fun ChatInfoToolbar( } DefaultTopAppBar( - navigationButton = { NavigationButtonBack(onBackClicked) }, + navigationButton = { if (appPlatform.isAndroid || showSearch) { NavigationButtonBack(onBackClicked) } }, title = { ChatInfoToolbarTitle(chat.chatInfo) }, onTitleClick = info, showSearch = showSearch, @@ -657,8 +659,8 @@ fun BoxWithConstraintsScope.ChatItemsList( .distinctUntilChanged() .filter { !stopListening } .collect { - onComposed() - stopListening = true + onComposed() + stopListening = true } } DisposableEffectOnGone( @@ -1016,8 +1018,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 +1056,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 +1110,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 +1182,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 77% 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..143815ddc2 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) } @@ -180,38 +170,18 @@ fun ComposeView( val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val maxFileSize = getMaxFileSize(FileProtocol.XFTP) val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - val textStyle = remember { mutableStateOf(smallFont) } - val cameraLauncher = rememberCameraLauncher { uri: Uri? -> - if (uri != null) { - val bitmap: Bitmap? = getBitmapFromUri(uri) - 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 textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } + 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() } } @@ -726,7 +655,11 @@ fun ComposeView( } else { showChooseAttachment } - IconButton(attachmentClicked, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) { + IconButton( + attachmentClicked, + Modifier.padding(bottom = if (appPlatform.isAndroid) 0.dp else 7.dp), + enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value + ) { Icon( painterResource(MR.images.ic_attach_file_filled_500), contentDescription = stringResource(MR.strings.attach), @@ -775,32 +708,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 +760,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..af8f4520b2 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( @@ -109,10 +83,9 @@ fun SendMsgView( if (showDeleteTextButton.value) { DeleteTextButton(composeState) } - Box(Modifier.align(Alignment.BottomEnd)) { + Box(Modifier.align(Alignment.BottomEnd).padding(bottom = if (appPlatform.isAndroid) 0.dp else 5.dp)) { 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..1b25c52cfe 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.end.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 94% 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..e9a9854456 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 @@ -49,7 +50,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch allowModifyMembers = allowModifyMembers, searchText, openPreferences = { - ModalManager.shared.showCustomModal { close -> + ModalManager.end.showCustomModal { close -> GroupPreferencesView(chatModel, groupInfo.id, close) } }, @@ -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 92% 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..4b37badfeb 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 @@ -54,7 +54,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR addMembers = { withApi { setGroupMembers(groupInfo, chatModel) - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(true) { close -> AddGroupMembersView(groupInfo, false, chatModel, close) } } @@ -73,7 +73,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR } else { member to null } - ModalManager.shared.showModalCloseable(true) { closeCurrent -> + ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { closeCurrent() @@ -84,13 +84,13 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR } }, editGroupProfile = { - ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) } + ModalManager.end.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) } }, addOrEditWelcomeMessage = { - ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) } + ModalManager.end.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) } }, openPreferences = { - ModalManager.shared.showCustomModal { close -> + ModalManager.end.showCustomModal { close -> GroupPreferencesView( chatModel, chat.id, @@ -102,7 +102,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) } } ) } @@ -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..bfe9aff82b 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") @@ -150,7 +149,7 @@ fun GroupMemberInfoView( }) }, verifyClicked = { - ModalManager.shared.showModalCloseable { close -> + ModalManager.end.showModalCloseable { close -> remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> VerifyCodeView( mem.displayName, @@ -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 71% 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..6430a23db8 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,14 @@ 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.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import java.io.File +import java.net.URI @Composable fun CIImageView( @@ -95,18 +86,18 @@ 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(DEFAULT_MAX_IMAGE_WIDTH, 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 + // .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView // if text is short and take all available width if text is long modifier = Modifier - .width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else 1000.dp) + .width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) .combinedClickable( onLongClick = { showMenu.value = true }, onClick = onClick @@ -116,14 +107,14 @@ fun CIImageView( } @Composable - fun imageView(painter: Painter, onClick: () -> Unit) { + fun ImageView(painter: Painter, onClick: () -> Unit) { Image( painter, contentDescription = stringResource(MR.strings.image_descr), - // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView + // .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView // if text is short and take all available width if text is long modifier = Modifier - .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else 1000.dp) + .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) .combinedClickable( onLongClick = { showMenu.value = true }, onClick = onClick @@ -139,8 +130,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 +140,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 +182,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 65% 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..21c27fa34e 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,20 +7,25 @@ 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 fun CIInvalidJSONView(json: String) { Row(Modifier - .clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } } + .clickable { + ModalManager.center.closeModals() + ModalManager.end.closeModals() + ModalManager.center.showModal(true) { InvalidJSONView(json) } + } .padding(horizontal = 10.dp, vertical = 6.dp) ) { Text(stringResource(MR.strings.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic) @@ -32,8 +37,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 81% 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..22d89e767c 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,15 +37,14 @@ 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 -> + ModalManager.fullscreen.showCustomModal(animated = false) { close -> ImageFullScreenView(imageProvider, 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) @@ -124,21 +113,13 @@ 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() } - ) + val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } + 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 } + val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } 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() { @@ -335,5 +311,5 @@ private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { private fun videoViewFullWidth(windowWidth: Dp): Dp { val approximatePadding = 100.dp - return minOf(1000.dp, windowWidth - approximatePadding) + return minOf(DEFAULT_MAX_IMAGE_WIDTH, windowWidth - approximatePadding) } 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 94% 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..73cb007898 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,21 @@ 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.appPlatform +import chat.simplex.common.platform.ntfManager import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.datetime.Clock @@ -72,7 +74,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ChatListNavLinkLayout( chatLinkPreview = { ContactConnectionView(chat.chatInfo.contactConnection) }, click = { - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.center.closeModals() + ModalManager.end.closeModals() + ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> ContactConnectionInfoView(chatModel, chat.chatInfo.contactConnection.connReqInv, chat.chatInfo.contactConnection, false, close) } }, @@ -86,7 +90,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { InvalidDataView() }, click = { - ModalManager.shared.showModal(true) { InvalidJSONView(chat.chatInfo.json) } + ModalManager.end.closeModals() + ModalManager.center.showModal(true) { InvalidJSONView(chat.chatInfo.json) } }, dropdownMenuItems = null, showMenu, @@ -205,7 +210,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 } ) @@ -341,7 +346,9 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: stringResource(MR.strings.set_contact_name), painterResource(MR.images.ic_edit), onClick = { - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.center.closeModals() + ModalManager.end.closeModals() + ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close) } showMenu.value = false @@ -548,7 +555,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 +600,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 +636,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 +676,11 @@ fun PreviewChatListNavLinkDirect() { } } -@Preview -@Preview( +@Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, name = "Dark Mode" -) +)*/ @Composable fun PreviewChatListNavLinkGroup() { SimpleXTheme { @@ -711,12 +716,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 80% 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..ec5d0071c0 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.* @@ -15,28 +14,27 @@ import androidx.compose.ui.graphics.* 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.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.SettingsViewState +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) { +fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } - val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } val showNewChatSheet = { newChatSheetState.value = AnimatedViewState.VISIBLE } @@ -47,7 +45,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: LaunchedEffect(Unit) { if (shouldShowWhatsNew(chatModel)) { delay(1000L) - ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) } + ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) } } } LaunchedEffect(chatModel.clearOverlays.value) { @@ -60,13 +58,13 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: connectIfOpenedViaUri(url, chatModel) } } + val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp var searchInList by rememberSaveable { mutableStateOf("") } - val scaffoldState = rememberScaffoldState() val scope = rememberCoroutineScope() - val switchingUsers = rememberSaveable { mutableStateOf(false) } - Scaffold(topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } }, + val (userPickerState, scaffoldState, switchingUsers ) = settingsState + Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } } }, scaffoldState = scaffoldState, - drawerContent = { SettingsView(chatModel, setPerformLA) }, + drawerContent = { SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) }, drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), floatingActionButton = { if (searchInList.isEmpty()) { @@ -76,7 +74,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet() } }, - Modifier.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp), + Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp), elevation = FloatingActionButtonDefaults.elevation( defaultElevation = 0.dp, pressedElevation = 0.dp, @@ -91,7 +89,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: } } ) { - Box(Modifier.padding(it)) { + Box(Modifier.padding(it).padding(end = endPadding)) { Column( modifier = Modifier .fillMaxSize() @@ -112,8 +110,10 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: if (searchInList.isEmpty()) { NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } - UserPicker(chatModel, userPickerState, switchingUsers) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + if (appPlatform.isAndroid) { + UserPicker(chatModel, userPickerState, switchingUsers) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + } } if (switchingUsers.value) { Box( @@ -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 83% 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..2d0ed7eb53 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,23 +11,26 @@ 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.SettingsViewState +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.common.platform.appPlatform import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow @Composable -fun ShareListView(chatModel: ChatModel, stopped: Boolean) { +fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { var searchInList by rememberSaveable { mutableStateOf("") } - val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } - val switchingUsers = rememberSaveable { mutableStateOf(false) } + val (userPickerState, scaffoldState, switchingUsers) = settingsState + val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp Scaffold( + Modifier.padding(end = endPadding), + scaffoldState = scaffoldState, topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } }, ) { Box(Modifier.padding(it)) { @@ -45,9 +46,11 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) { } } } - UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = { - chatModel.sharedContent.value = null - }) + if (appPlatform.isAndroid) { + UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = { + chatModel.sharedContent.value = null + }) + } } @Composable @@ -83,7 +86,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..5381ebc283 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.* @@ -44,6 +41,11 @@ fun UserPicker( ) { val scope = rememberCoroutineScope() var newChat by remember { mutableStateOf(userPickerState.value) } + if (newChat.isVisible()) { + BackHandler { + userPickerState.value = AnimatedViewState.HIDING + } + } val users by remember { derivedStateOf { chatModel.users @@ -91,10 +93,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 { @@ -124,6 +126,7 @@ fun UserPicker( delay(500) switchingUsers.value = true } + ModalManager.closeAllModalsEverywhere() chatModel.controller.changeActiveUser(u.user.userId, null) job.cancel() switchingUsers.value = false @@ -168,9 +171,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 +200,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 56% 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..8738db2df1 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), @@ -113,7 +83,7 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) { if (fileDeleted) { m.controller.appPrefs.chatArchiveName.set(null) m.controller.appPrefs.chatArchiveTime.set(null) - ModalManager.shared.closeModal() + ModalManager.start.closeModal() } else { Log.e(TAG, "deleteArchiveAlert delete() error") } @@ -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..8c26011e92 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,16 +352,16 @@ 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) { /** Hide current view and show [DatabaseErrorView] */ - ModalManager.shared.closeModals() + ModalManager.closeAllModalsEverywhere() return@withApi } if (m.currentUser.value == null) { - ModalManager.shared.closeModals() + ModalManager.closeAllModalsEverywhere() return@withApi } else { m.controller.apiStartChat() @@ -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 84% 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..848b21eb44 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,13 +10,13 @@ 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 = {}) { +fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}) { Column( Modifier .fillMaxWidth() @@ -33,7 +32,11 @@ fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> U horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - NavigationButtonBack(onButtonClicked = close) + if (showClose) { + NavigationButtonBack(onButtonClicked = close) + } else { + Spacer(Modifier) + } Row { endButtons() } @@ -63,12 +66,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..8e6c9fffc1 --- /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.fullscreen.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 70% 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..ec3ee8ecea 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,59 +1,58 @@ -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.model.ChatModel +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* import java.util.concurrent.atomic.AtomicBoolean @Composable fun ModalView( close: () -> Unit, + showClose: Boolean = true, background: Color = MaterialTheme.colors.background, modifier: Modifier = Modifier, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit, ) { - BackHandler(onBack = close) + if (showClose) { + BackHandler(onBack = close) + } Surface(Modifier.fillMaxSize()) { Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) { - CloseSheetBar(close, endButtons) + CloseSheetBar(close, showClose, endButtons) Box(modifier) { content() } } } } -class ModalManager { +enum class ModalPlacement { + START, CENTER, END, FULLSCREEN +} + +class ModalManager(private val placement: ModalPlacement? = null) { private val modalViews = arrayListOf Unit) -> Unit)>>() private val modalCount = mutableStateOf(0) private val toRemove = mutableSetOf() private var oldViewChanging = AtomicBoolean(false) private var passcodeView: MutableState<(@Composable (close: () -> Unit) -> Unit)?> = mutableStateOf(null) - fun showModal(settings: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) { + fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable () -> Unit) { showCustomModal { close -> - ModalView(close, endButtons = endButtons, content = content) + ModalView(close, showClose = showClose, endButtons = endButtons, content = content) } } - fun showModalCloseable(settings: Boolean = false, content: @Composable (close: () -> Unit) -> Unit) { + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, content: @Composable (close: () -> Unit) -> Unit) { showCustomModal { close -> - ModalView(close, content = { content(close) }) + ModalView(close, showClose = showClose, content = { content(close) }) } } @@ -64,8 +63,17 @@ class ModalManager { if (toRemove.isNotEmpty()) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } } - modalViews.add(animated to modal) + // Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0) + // to prevent unneeded animation on different situations + val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START) + modalViews.add(anim to modal) modalCount.value = modalViews.size - toRemove.size + + if (placement == ModalPlacement.CENTER) { + ChatModel.chatId.value = null + } else if (placement == ModalPlacement.END) { + desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH) + } } fun showPasscodeCustomModal(modal: @Composable (close: () -> Unit) -> Unit) { @@ -155,6 +163,17 @@ private fun animationSpec() = tween(durationMillis = 250, easing = FastOu // private fun animationSpecFromEnd() = tween(durationMillis = 100, easing = FastOutSlowInEasing) companion object { - val shared = ModalManager() + private val shared = ModalManager() + val start = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.START) + val center = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.CENTER) + val end = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.END) + val fullscreen = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.FULLSCREEN) + + fun closeAllModalsEverywhere() { + start.closeModals() + center.closeModals() + end.closeModals() + fullscreen.closeModals() + } } } 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 79% 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..756e605dc3 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()}") } @@ -71,7 +70,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( if (createdUser != null) { m.controller.startChat(createdUser) } - ModalManager.shared.closeModals() + ModalManager.fullscreen.closeModals() AlertManager.shared.hideAlert() completed(LAResult.Success) } catch (e: Exception) { 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 90% 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..2ee2145259 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,24 +12,25 @@ 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 { + ModalManager.center.showModal { Column( Modifier .fillMaxHeight() @@ -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..3ab5ef9b2b 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) { @@ -47,7 +45,7 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { chatModel.chatId.value = groupInfo.id setGroupMembers(groupInfo, chatModel) close.invoke() - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(true) { close -> AddGroupMembersView(groupInfo, true, chatModel, close) } } @@ -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 84% 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..2b971282b3 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 @@ -40,11 +42,12 @@ fun ContactConnectionInfoView( **/ DisposableEffect(Unit) { onDispose { - if (!ModalManager.shared.hasModalsOpen()) { + if (!ModalManager.center.hasModalsOpen()) { chatModel.connReqInv.value = null } } } + val clipboard = LocalClipboardManager.current ContactConnectionInfoLayout( connReq = connReqInvitation, contactConnection, @@ -52,9 +55,9 @@ 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 { + ModalManager.center.showModal { Column( Modifier .fillMaxHeight() @@ -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 90% 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..53f7bdcc73 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 { @@ -37,7 +35,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { **/ DisposableEffect(Unit) { onDispose { - if (!ModalManager.shared.hasModalsOpen()) { + if (!ModalManager.center.hasModalsOpen()) { m.connReqInv.value = null } } 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 88% 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..171465d86d 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 @@ -42,21 +40,28 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow ConnectViaLinkView(chatModel, close) } + ModalManager.center.closeModals() + ModalManager.center.showModalCloseable { close -> ConnectViaLinkView(chatModel, close) } }, createGroup = { closeNewChatSheet(false) - ModalManager.shared.showCustomModal { close -> AddGroupView(chatModel, close) } + ModalManager.center.closeModals() + ModalManager.center.showCustomModal { close -> AddGroupView(chatModel, close) } }, closeNewChatSheet, ) } -private val titles = listOf(MR.strings.share_one_time_link, MR.strings.connect_via_link_or_qr, MR.strings.create_group) +private val titles = listOf( + MR.strings.share_one_time_link, + if (appPlatform.isAndroid) MR.strings.connect_via_link_or_qr else MR.strings.connect_via_link, + MR.strings.create_group +) private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group) @Composable @@ -91,11 +96,13 @@ private fun NewChatSheetLayout( } } } - val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density } + val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp + val maxWidth = with(LocalDensity.current) { screenWidth() * density } Column( Modifier .fillMaxSize() - .offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else 0, 0) } + .padding(end = endPadding) + .offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else 0, 0) } .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { closeNewChatSheet(true) } .drawBehind { drawRect(animatedColor.value) }, verticalArrangement = Arrangement.Bottom, @@ -103,7 +110,7 @@ private fun NewChatSheetLayout( ) { val actions = remember { listOf(addContact, connectViaLink, createGroup) } val backgroundColor = if (isInDarkTheme()) - Color(ColorUtils.blendARGB(MaterialTheme.colors.primary.toArgb(), Color.Black.toArgb(), 0.7F)) + blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F) else MaterialTheme.colors.background LazyColumn(Modifier diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt similarity index 82% rename from apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index f7f1770abd..ba8b8df548 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -1,43 +1,38 @@ -package chat.simplex.app.views.newchat +package chat.simplex.common.views.newchat import SectionBottomSpacer -import android.content.ClipboardManager -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.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat.getSystemService -import chat.simplex.app.R -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.platform.TAG +import chat.simplex.common.model.ChatModel +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR +import java.net.URI @Composable fun PasteToConnectView(chatModel: ChatModel, close: () -> 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 63% 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..5e43a34197 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,23 +1,20 @@ -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.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.* +import androidx.compose.ui.unit.dp 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 @@ -30,17 +27,18 @@ fun QRCode( ) { val scope = rememberCoroutineScope() - BoxWithConstraints { + BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { val maxWidthInPx = with(LocalDensity.current) { maxWidth.roundToPx() } val qr = remember(maxWidthInPx, connReq, tintColor, withLogo) { qrCodeBitmap(connReq, maxWidthInPx).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) .let { if (withLogo) it.addLogo() else it } - .asImageBitmap() } Image( bitmap = qr, contentDescription = stringResource(MR.strings.image_descr_qr_code), - modifier + Modifier + .widthIn(max = 500.dp) + .then(modifier) .clickable { scope.launch { val image = qrCodeBitmap(connReq, 1024).replaceColor(Color.Black.toArgb(), tintColor.toArgb()) @@ -55,7 +53,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 +67,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 88% 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..7785180926 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) { @@ -43,7 +42,7 @@ fun HowItWorks(user: User?, onboardingStage: MutableState? = n if (onboardingStage != null) { Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING), contentAlignment = Alignment.Center) { - OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.shared.closeModal() }) + OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() }) } Spacer(Modifier.fillMaxHeight().weight(1f)) } @@ -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 90% 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..1e69d92c22 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 @@ -30,7 +26,7 @@ fun SimpleXInfo(chatModel: ChatModel, onboarding: Boolean = true) { SimpleXInfoLayout( user = chatModel.currentUser.value, onboardingStage = if (onboarding) chatModel.onboardingStage else null, - showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, + showModal = { modalView -> { if (onboarding) ModalManager.fullscreen.showModal { modalView(chatModel) } else ModalManager.start.showModal { modalView(chatModel) } } }, ) } @@ -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..f9c85ca56d --- /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 = chat.simplex.common.ui.theme.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 = chat.simplex.common.ui.theme.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 67% 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..a3da6c6c90 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) - } + ModalManager.start.showModalCloseable(true) { + 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 87% 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..9c7c4e25c5 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() } @@ -283,7 +278,7 @@ fun SimplexLockView( } } LAMode.PASSCODE -> { - ModalManager.shared.showCustomModal { close -> + ModalManager.fullscreen.showCustomModal { close -> Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { SetAppPasscodeView( submit = { @@ -311,7 +306,7 @@ fun SimplexLockView( is LAResult.Failed -> { /* Can be called multiple times on every failure */ } LAResult.Success -> { if (!selfDestruct.get()) { - ModalManager.shared.showCustomModal { close -> + ModalManager.fullscreen.showCustomModal { close -> EnableSelfDestruct(selfDestruct, close) } } else { @@ -327,7 +322,7 @@ fun SimplexLockView( authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.la_change_app_passcode)) { laResult -> when (laResult) { LAResult.Success -> { - ModalManager.shared.showCustomModal { close -> + ModalManager.fullscreen.showCustomModal { close -> Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { SetAppPasscodeView( submit = { @@ -350,7 +345,7 @@ fun SimplexLockView( authenticate(generalGetString(MR.strings.la_current_app_passcode), generalGetString(MR.strings.change_self_destruct_passcode)) { laResult -> when (laResult) { LAResult.Success -> { - ModalManager.shared.showCustomModal { close -> + ModalManager.fullscreen.showCustomModal { close -> Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { SetAppPasscodeView( passcodeKeychain = ksSelfDestructPassword, @@ -385,7 +380,7 @@ fun SimplexLockView( setPerformLA(true) } LAMode.PASSCODE -> { - ModalManager.shared.showCustomModal { close -> + ModalManager.fullscreen.showCustomModal { close -> Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { SetAppPasscodeView( submit = { @@ -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) + } } } @@ -430,7 +427,7 @@ fun SimplexLockView( SectionDividerSpaced() SectionView(stringResource(MR.strings.self_destruct_passcode).uppercase()) { val openInfo = { - ModalManager.shared.showModal { + ModalManager.start.showModal { SelfDestructInfoView() } } @@ -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 92% 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..92246b72fb 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 @@ -62,7 +62,7 @@ fun ProtocolServersView(m: ChatModel, serverProtocol: ServerProtocol, close: () } fun showServer(server: ServerCfg) { - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.start.showModalCloseable(true) { close -> var old by remember { mutableStateOf(server) } val index = servers.indexOf(old) ProtocolServerView( @@ -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.start.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 91% 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..089ec7713f 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 androidx.compose.ui.unit.dp +import chat.simplex.common.model.ChatModel +import chat.simplex.common.platform.* import chat.simplex.res.MR +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* @Composable fun SetDeliveryReceiptsView(m: ChatModel) { @@ -73,8 +73,9 @@ private fun SetDeliveryReceiptsLayout( skip: () -> Unit, userCount: Int, ) { + val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp Column( - Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING), + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = DEFAULT_PADDING, end = endPadding), horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarTitle(stringResource(MR.strings.delivery_receipts_title)) 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 86% 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..fda24031aa 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,23 +21,19 @@ 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.* +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 +import kotlinx.coroutines.launch @Composable -fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { +fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) { val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false @@ -53,25 +49,25 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { chatModel.controller.appPrefs.incognito, user.displayName, setPerformLA = setPerformLA, - showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.shared.showModal(true) { modalView(chatModel) } } }, + showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, + showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, showSettingsModalWithSearch = { modalView -> - ModalManager.shared.showCustomModal { close -> + ModalManager.start.showCustomModal { close -> val search = rememberSaveable { mutableStateOf("") } 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) }) } }, - showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } }, + showCustomModal = { modalView -> { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) } } }, showVersion = { withApi { val info = chatModel.controller.apiGetVersion() if (info != null) { - ModalManager.shared.showModal { VersionInfoView(info) } + ModalManager.start.showModal { VersionInfoView(info) } } } }, @@ -80,7 +76,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { block() } else { var autoShow = true - ModalManager.shared.showModalCloseable { close -> + ModalManager.fullscreen.showModalCloseable { close -> val onFinishAuth = { success: Boolean -> if (success) { close() @@ -109,6 +105,7 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { } } }, + drawerState = drawerState, ) } } @@ -130,15 +127,25 @@ fun SettingsLayout( showSettingsModalWithSearch: (@Composable (ChatModel, MutableState) -> Unit) -> Unit, showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit), showVersion: () -> Unit, - withAuth: (title: String, desc: String, block: () -> Unit) -> Unit + withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, + drawerState: DrawerState, ) { + val scope = rememberCoroutineScope() + val closeSettings: () -> Unit = { scope.launch { drawerState.close() } } + if (drawerState.isOpen) { + BackHandler { + closeSettings() + } + } val theme = CurrentColors.collectAsState() val uriHandler = LocalUriHandler.current - Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).themedBackground(theme.value.base)) { + Box(Modifier.fillMaxSize()) { Column( Modifier .fillMaxSize() - .padding(top = DEFAULT_PADDING) + .verticalScroll(rememberScrollState()) + .themedBackground(theme.value.base) + .padding(top = if (appPlatform.isAndroid) DEFAULT_PADDING else DEFAULT_PADDING * 3) ) { AppBarTitle(stringResource(MR.strings.your_settings)) @@ -180,17 +187,31 @@ 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() } + if (appPlatform.isDesktop) { + Box( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.background) + .background(if (isInDarkTheme()) ToolbarDark else ToolbarLight) + .padding(start = 4.dp, top = 8.dp) + ) { + NavigationButtonBack(closeSettings) + } + } } } +@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 +294,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 +374,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 +511,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 { @@ -533,6 +533,7 @@ fun PreviewSettingsLayout() { showCustomModal = { {} }, showVersion = {}, withAuth = { _, _, _ -> }, + drawerState = DrawerState(DrawerValue.Closed), ) } } 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 94% 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..63f06a2aec 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, @@ -82,7 +85,7 @@ fun UserAddressView( } }, learnMore = { - ModalManager.shared.showModal { + ModalManager.start.showModal { Column( Modifier .fillMaxHeight() @@ -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 93% 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..ac3a68fc49 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,17 @@ 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.common.platform.appPlatform import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay @@ -47,11 +48,15 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice, visibleUsersCount = visibleUsersCount(m), addUser = { - ModalManager.shared.showModalCloseable { close -> + ModalManager.center.showModalCloseable { close -> CreateProfile(m, close) } }, activateUser = { user -> + if (appPlatform.isDesktop) { + ModalManager.center.closeModals() + ModalManager.end.closeModals() + } withBGApi { m.controller.changeActiveUser(user.userId, userViewPassword(user, searchTextOrPassword.value.trim())) } @@ -99,7 +104,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: }, unhideUser = { user -> if (passwordEntryRequired(user, searchTextOrPassword.value)) { - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.start.showModalCloseable(true) { close -> ProfileActionView(UserProfileAction.UNHIDE, user) { pwd -> withBGApi { setUserPrivacy(m) { m.controller.apiUnhideUser(user.userId, pwd) } @@ -122,7 +127,7 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: withBGApi { setUserPrivacy(m) { m.controller.apiUnmuteUser(user.userId) } } }, showHiddenProfile = { user -> - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.start.showModalCloseable(true) { close -> HiddenProfileView(m, user) { profileHidden.value = true withBGApi { @@ -328,7 +333,7 @@ private fun passwordEntryRequired(user: User, searchTextOrPassword: String): Boo private fun removeUser(m: ChatModel, user: User, users: List, delSMPQueues: Boolean, searchTextOrPassword: String) { if (passwordEntryRequired(user, searchTextOrPassword)) { - ModalManager.shared.showModalCloseable(true) { close -> + ModalManager.start.showModalCloseable(true) { close -> ProfileActionView(UserProfileAction.DELETE, user) { pwd -> withBGApi { doRemoveUser(m, user, users, delSMPQueues, pwd) 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..ac7451c389 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -267,6 +267,9 @@ You have no chats No filtered chats + + No selected chat + Share message… Share media… @@ -394,6 +397,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..936942d980 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -0,0 +1,151 @@ +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, width = 1366.dp, height = 768.dp) + simplexWindowState.windowState = windowState + 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 { + lateinit var windowState: WindowState + 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..ea806c0622 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -0,0 +1,103 @@ +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, 12.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 + ) { + Column(Modifier.weight(1f).padding(start = 12.dp, end = 32.dp)) { + Spacer(Modifier.height(8.dp)) + innerTextField() + Spacer(Modifier.height(10.dp)) + } + } + } + } + } + ) + 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..3a76808b0c --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -0,0 +1,63 @@ +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.* +import chat.simplex.common.simplexWindowState +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 desktopExpandWindowToWidth(width: Dp) { + if (simplexWindowState.windowState.size.width >= width) return + simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = width) +} + +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/Theme.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt new file mode 100644 index 0000000000..d7da2fa123 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt @@ -0,0 +1,10 @@ +package chat.simplex.common.ui.theme + +import com.jthemedetecor.OsThemeDetector + +private val detector: OsThemeDetector = OsThemeDetector.getDetector() + .apply { + registerListener(::reactOnDarkThemeChanges) + } + +actual fun isSystemInDarkTheme(): Boolean = detector.isDark 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..214946b1c9 --- /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.fullscreen.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..1a8ad010e3 --- /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.start.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/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 891c4308bb..77cbc79b93 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -26,7 +26,7 @@ android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 android.version_name=5.2 -android.version_code=133 +android.version_code=134 desktop.version_name=1.0 diff --git a/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md b/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md index 7f4514e98d..42c08245f8 100644 --- a/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md +++ b/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md @@ -2,10 +2,8 @@ layout: layouts/article.html title: "SimpleX Chat v5.2 released: message delivery receipts" date: 2023-07-22 -# image: images/20230523-reactions.png -# imageBottom: true -# previewBody: blog_previews/20230523.html -preview: TODO this is a placeholder for the release announcement +image: images/20230722-receipts-full.png +previewBody: blog_previews/20230722.html permalink: "/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html" --- @@ -13,26 +11,144 @@ permalink: "/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html" **Published:** July 22, 2023 -TODO this is a draft of the release announcement +**What's new in v5.2:** +- [message delivery receipts](#message-delivery-receipts) – with opt-out per contact! +- [filter favorite and unread chats](#filter-favorite-and-unread-chats). +- [more usable groups](#more-usable-groups): + - view full original replied message in info. + - share your address with your contacts and group members via your chat profile. + - search new and existing members. +- [stability improvements](#stability-improvements): + - keep your connections working after restoring from backup. + - restart app (Android) or reconnect servers (iOS). + - more reliable switching of the receiving address. + - more stable message delivery. +- other improvements: + - [better disappearing messages](#better-disappearing-messages). + - [prohibit message reactions](#prohibit-message-reactions). -What's new in v5.2: +**Platform evolution:** +- [problems of public groups](#problems-of-public-groups): + - why not all messages are received. + - how to cope with these problems. + - when will public groups be more usable. +- [what about read receipts?](#what-about-read-receipts) -- message delivery receipts -- improvements for groups - - view quoted messages - - share SimpleX address with your contacts via your chat profile - - search for group members -- feature allowing to fix connection encryption (for example after importing old database) -- mark chats as favorite and filter chats -- restart/reconnect -- chat preference to allow/prohibit message reactions -- disappearing messages improvements: - - send with custom timer when allowed but not enabled in the conversation - - increased timer limit +## What's new in v5.2 -## Future of SimpleX Chat groups and communities +### Message delivery receipts -TODO + + +Most messaging apps add two ticks to sent messages – the first one to show that the message is accepted by the server, and the second – that it is delivered to the recipient's device. It confirms that the network is functioning, and that the message is not lost or delayed. SimpleX Chat now has this feature too! + +In some cases it may compromise recipients' privacy, as they show that the recipient is online, so we made sending delivery receipts optional – it can be disabled separately for each chat profile or contact. For the new chat profiles this feature is enabled by default. + +To avoid compromising your privacy, sending delivery receipts is disabled for all your existing chat profiles. The first time you start the app after the update, you will be offered to enable them for all [visible profiles](./20230328-simplex-chat-v4-6-hidden-profiles.md#hidden-chat-profiles), and they can be enabled later via Privacy and Security settings. + +### Filter favorite and unread chats + + + +You can now mark your contacts and groups as _favorite_, to be able to find them faster. With filter enabled, you will only see favorite chats, chats that contain unread messages and also any unaccepted group invitations and contact requests. + +### More usable groups + +Active SimpleX Chat users know how broken the current group experience is, and that we plan some major overhaul of the groups protocol – more on that below. In the meanwhile, we added some simple features that make groups in their current state a bit more usable. + +#### What is this in reply to? + + + +A major problem is that you can see replies to the messages you've not seen before - this would happen both when you just join the group, and didn't connect to most other members, and also when other new members join the group and they didn't yet connect to you – so literally all the time, and the bigger the group gets, the worse it becomes. While this problem cannot be solved without major group protocol changes, at least there is now ability to see the original message that was replied to via the message information. + +#### How to connect to this member? + + + +To simplify direct connections with other group members, you can now share your SimpleX address via your chat profile, and group members can send you a contact request even if the group does not allow direct messages. + +#### How to find a member in the list? + +Large member lists (and also the long lists of contacts, if you have many of them) become hard to scroll through, so now there is a search in both lists. + +### Stability improvements + +This version fixes many long-standing problems with the message delivery, failed connections with group members (that also contributed to group fragmentation), and reduces traffic in groups (beta users experienced a traffic spike because of this fix, but it doesn't affect the final v5.2 release). It's not the end of the road to making SimpleX Chat as stable as mainstream messengers, but it is a big improvement. + +Please report the cases when messages are not delivered – delivery receipts should help with that. + +#### Messages failed to decrypt? Problem solved! + +Previously, a growing number of users had the issue when after restoring the chat database from backup, messages from some contacts failed to decrypt and were showing an error in the app. + +This happens due to double ratchet protocol protecting the integrity of end-to-end encryption after the compromise - [post-compromise security](../docs/GLOSSARY.md#post-compromise-security). The protocol logic does not allow to use the old version of the database to decrypt the message. + +v5.2 added the extension to the messaging protocol allowing to negotiate the new ratchet keys in such cases - both with the contacts and the group members. This requires a user action, and it resets the security code verification status for this contact or member – you need to verify it again to have the additional protection from [man-in-the-middle attacks](../docs/GLOSSARY.md#man-in-the-middle-attack). + +The negotiation of the new ratchet keys still happens via the end-to-end encrypted messages, as the protocol has two layers of end-to-end encryption, so it cannot be compromised by the messaging relays. + +You may still lose connection if you or your contact changed the receiving address after you made the backup, so make sure to make a new backup after any receiving address changes. + +#### Reconnect the servers + +While v5.2 solved many message delivery issues, there may be some others, but they are usually resolved with app restart. It was difficult to fully restart Android app, as there is a continuosly running background service for notifications that doesn't restart with the app. Now Android app has both Restart and Shutdown buttons that take background service into account. + +On iOS you can now pull down the list of conversations to reconnect to all relays without restarting the app. + +### Better disappearing messages + +You can now send a separate disappearing message if the chat preferences allow them, but do not have any time to disappear enabled – this applies both to groups and to contacts. You can also set the time to disappear up to 12 months. + +### Prohibit message reactions + +While most people like message reactions, some conversations make them inappropriate - you can disable them now. + +## Platform evolution + +### Problems of public groups + +As I wrote above, the major problem is that not all messages are received by all members, or, at least, they may be substantially delayed. Additional problems are various scenarios when the list of members gets out of sync for different members. + +How to cope with these problems? + +It really helps to only use one link shared with the members to join the group - the one created by the client that is most frequently online, ideally always online. This is sometimes confusing, as any group admin can create another group link, and share it with the members, and if this admin is not online, the new member won't be able to join. + +We will add new group features to manage fragmentation - there will be an option to fix the connection with the member that you failed to connect to by passing the link out-of-band. This can be particularly helpful for stable groups of 20-50 people where it's important to see all messages. + +In the long term, the only way to make groups usable is to move to a new design. We considered several options. + +#### Why not hosted groups with MLS? + +Initially, we considered the design with the dedicated servers, potentially self-hosted, that host groups. This design would require adopting MLS (or similar) protocol for group-wide key agreement. Unfortunately, this design is not sufficiently resilient and easier to censor than decentralized design. Also, MLS protocol is very complex to implement, requires a centralized component, and reduces forward secrecy. So we decided against this approach. + +#### Why not fully decentralized groups? + +We also [considered](../docs/rfcs/2023-05-02-groups.md) rumour-mongering protocol, where all members are equal and participate in message dissemination. The problem with this approach is that it adds a lot of traffic for all members, even those who mostly read messages. Also, it still requires establishing a fully connected graph, and with large groups it becomes prohibitively expensive and unreliable, given that many members join public groups for a limited time. + +#### Members host the groups + +We are now considering a middle-ground - the design where the owners and admins host the group, synchronising the state between them, receiving and re-broadcasting the messages between all members. This puts a higher burden on these members, but these clients can be hosted in the cloud, and also group owners have a larger incentive to maintain group integrity. At the same time, this design is better for the rest of the group members, as they don't need to establish connections with all other members, only with a limited number of "hosting" members, and it also better protects their privacy, due to the lack of direct connections between most members. + +This approach avoids the need for a group-wide key agreement protocol, as hosting members are expected to have access to all content anyway, so pair-wise ratchets are sufficient. At the same time the content remains end-to-end encrypted, and protected from the outsiders. + +This approach also simplifies moderation - the message that needs to be removed simply won't reach the members before it is moderated (in case of automatic or policy-based moderation). + +Discovery and content search in such groups will be provided via a dedicated discovery server that will participate in the group, provide an always-online client, and also automatic content moderation functionality - a possible approach to moderation is [described here](../docs/rfcs/2023-05-22-groups-moderation.md). + +We really look forward to your feedback on this design. + +### What about read receipts? + +We have an approximately equal number of users who ask us to add receipts, and those who ask not to add them, even as optional. + +While read receipts provide some convenience to the message senders, they introduce a lot of stress for the recipients. + +As one of the users in the group wrote it: "The existence of read receipts in other platforms is exhausting and is often a source of undue stress. I have to make a decision to read something and let someone know that I have read something and decided not to respond or merely didn't have the time to respond. The outcome of that is a complex social negotiation with non-theoretical social fallout as a consequence. All in all, it's an invasion of privacy of being able to read things at the pace of the individual as opposed to the pace dictated by others... Most people don't need a read receipt, so leave it to a group of individuals to decide if having read receipts make sense to them for their workflow". + +Also read [this post](https://neilalexander.dev/2021/04/09/read-receipts-typing-notifs) about the damage from read receipts and other invasive features, like typing and presense notifications. + +There is also no discounting that the presense of read receipts functionality, even as opt-in, creates a social pressure to enable them, with the same consequences – there are many scenarios when they become non-optional in some relationships. So many users belive, and we share this view, that it is better not to have these features at all. We will be re-assessing this view. ## SimpleX platform diff --git a/blog/README.md b/blog/README.md index 56c39589a2..129ba2c80d 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,31 @@ # Blog +July 22, 2023 [SimpleX Chat v5.2 released](./20230722-simplex-chat-v5-2-message-delivery-receipts.md) + +**What's new in v5.2:** + +- message delivery receipts – with opt-out per contact! +- filter favorite and unread chats. +- more usable groups: + - view full original replied message in info. + - share your address with your contacts and group members via your chat profile. + - search new and existing members. +- stability improvements: + - keep your connections working after restoring from backup. + - restart app (Android) or reconnect servers (iOS). + - more reliable switching of the receiving address. + - more stable message delivery. +- other improvements: + - better disappearing messages. + - prohibit message reactions. + +**Platform evolution:** + +- problems of public groups problems of public groups and their future. +- what about read receipts? + +--- + May 23, 2023 [SimpleX Chat v5.1 released](./20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md) What's new in v5.1: diff --git a/blog/images/20230722-filter.png b/blog/images/20230722-filter.png new file mode 100644 index 0000000000..5845c7eb3b Binary files /dev/null and b/blog/images/20230722-filter.png differ diff --git a/blog/images/20230722-quoted.png b/blog/images/20230722-quoted.png new file mode 100644 index 0000000000..d812d43691 Binary files /dev/null and b/blog/images/20230722-quoted.png differ diff --git a/blog/images/20230722-receipts-full.png b/blog/images/20230722-receipts-full.png new file mode 100644 index 0000000000..f1eb2be35f Binary files /dev/null and b/blog/images/20230722-receipts-full.png differ diff --git a/blog/images/20230722-receipts.png b/blog/images/20230722-receipts.png new file mode 100644 index 0000000000..536216e3d0 Binary files /dev/null and b/blog/images/20230722-receipts.png differ diff --git a/blog/images/20230722-search.png b/blog/images/20230722-search.png new file mode 100755 index 0000000000..605d8cf51a Binary files /dev/null and b/blog/images/20230722-search.png differ diff --git a/cabal.project b/cabal.project index a9aa3f5d89..b747dff8be 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 7e2b30945009e0cbe02a6b11a585595d3e4858b0 + tag: fdbfe0e8d159f394f6eb0f5168620da8694033cd source-repository-package type: git diff --git a/package.yaml b/package.yaml index 76b9e584aa..c4dcecd981 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.2.0.3 +version: 5.2.0.4 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme 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/ diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1d1036a334..3626f25eb2 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."7e2b30945009e0cbe02a6b11a585595d3e4858b0" = "0g0qlnnzzfiajpnnklxnk83p54mnr94483pj87rv1lchn4whfrpx"; + "https://github.com/simplex-chat/simplexmq.git"."fdbfe0e8d159f394f6eb0f5168620da8694033cd" = "0c0r14kzcp9rmc1nxypknaa93aczl13yxyaj1c0zrd1b17c2m8cx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 526051edb6..cad28ffe73 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -1,11 +1,11 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.35.1. +-- This file has been generated from package.yaml by hpack version 0.35.0. -- -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.2.0.3 +version: 5.2.0.4 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -105,6 +105,7 @@ library Simplex.Chat.Migrations.M20230618_favorite_chats Simplex.Chat.Migrations.M20230621_chat_item_moderations Simplex.Chat.Migrations.M20230705_delivery_receipts + Simplex.Chat.Migrations.M20230721_group_snd_item_statuses Simplex.Chat.Mobile Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options @@ -125,6 +126,8 @@ library Simplex.Chat.Terminal.Notification Simplex.Chat.Terminal.Output Simplex.Chat.Types + Simplex.Chat.Types.Preferences + Simplex.Chat.Types.Util Simplex.Chat.Util Simplex.Chat.View other-modules: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9f4988271d..514553a574 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -67,6 +67,8 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Util import Simplex.FileTransfer.Client.Main (maxFileSize) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.FileTransfer.Description (ValidFileDescription, gb, kb, mb) @@ -157,6 +159,9 @@ maxMsgReactions = 3 fixedImagePreview :: ImageData fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==" +smallGroupsRcptsMemLimit :: Int +smallGroupsRcptsMemLimit = 20 + logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} @@ -395,6 +400,12 @@ processChatCommand = \case withStore' $ \db -> updateUserContactReceipts db user' settings ok user SetUserContactReceipts settings -> withUser $ \User {userId} -> processChatCommand $ APISetUserContactReceipts userId settings + APISetUserGroupReceipts userId' settings -> withUser $ \user -> do + user' <- privateGetUser userId' + validateUserPassword user user' Nothing + withStore' $ \db -> updateUserGroupReceipts db user' settings + ok user + SetUserGroupReceipts settings -> withUser $ \User {userId} -> processChatCommand $ APISetUserGroupReceipts userId settings APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of @@ -492,10 +503,16 @@ processChatCommand = \case chatItems <- withStore $ \db -> getAllChatItems db user pagination search pure $ CRChatItems user chatItems APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do - (aci@(AChatItem _ _ _ ci), versions) <- withStore $ \db -> + (aci@(AChatItem cType dir _ ci), versions) <- withStore $ \db -> (,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId) let itemVersions = if null versions then maybeToList $ mkItemVersion ci else versions - pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions} + memberDeliveryStatuses <- case (cType, dir) of + (SCTGroup, SMDSnd) -> do + withStore' (`getGroupSndStatuses` itemId) >>= \case + [] -> pure Nothing + memStatuses -> pure $ Just $ map (uncurry MemberDeliveryStatus) memStatuses + _ -> pure Nothing + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId @@ -570,9 +587,12 @@ processChatCommand = \case (fInv_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer g (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership - msg@SndMessage {sharedMsgId} <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) + (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) mapM_ (sendGroupFileInline ms sharedMsgId) ft_ ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live + withStore' $ \db -> + forM_ sentToMembers $ \GroupMember {groupMemberId} -> + createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) setActive $ ActiveG gName @@ -706,7 +726,7 @@ processChatCommand = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withStore' $ \db -> do currentTs <- liftIO getCurrentTime when changed $ @@ -740,7 +760,7 @@ processChatCommand = \case (CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier - SndMessage {msgId} <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId Nothing + (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId Nothing delGroupChatItem user gInfo ci msgId Nothing (CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete CTContactRequest -> pure $ chatCmdError (Just user) "not supported" @@ -752,7 +772,7 @@ processChatCommand = \case (CIGroupRcv GroupMember {groupMemberId, memberRole, memberId}, Just itemSharedMId) -> do when (groupMemberId /= mId) $ throwChatError CEInvalidChatItemDelete assertUserGroupRole gInfo $ max GRAdmin memberRole - SndMessage {msgId} <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId + (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId delGroupChatItem user gInfo ci msgId (Just membership) (_, _) -> throwChatError CEInvalidChatItemDelete APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> withChatLock "chatItemReaction" $ case cType of @@ -784,7 +804,7 @@ processChatCommand = \case let GroupMember {memberId = itemMemberId} = chatItemMember g ci rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) + (SndMessage {msgId}, _) <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) createdAt <- liftIO getCurrentTime reactions <- withStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt @@ -1407,7 +1427,7 @@ processChatCommand = \case (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole + (msg, _) <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} @@ -1423,7 +1443,7 @@ processChatCommand = \case deleteMemberConnection user m withStore' $ \db -> deleteGroupMember db user m _ -> do - msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId + (msg, _) <- sendGroupMessage user gInfo members $ XGrpMemDel mId ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) deleteMemberConnection user m @@ -1433,7 +1453,7 @@ processChatCommand = \case APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId withChatLock "leaveGroup" . procCmd $ do - msg <- sendGroupMessage user gInfo members XGrpLeave + (msg, _) <- sendGroupMessage user gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) -- TODO delete direct connections that were unused @@ -1821,7 +1841,7 @@ processChatCommand = \case runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p} ms) p' = do assertUserGroupRole g GROwner g' <- withStore $ \db -> updateGroupProfile db user g p' - msg <- sendGroupMessage user g' ms (XGrpInfo p') + (msg, _) <- sendGroupMessage user g' ms (XGrpInfo p') let cd = CDGroupSnd g' unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') @@ -2869,12 +2889,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do SENT msgId -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId - withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId msgId) >>= \case - Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _}}) -> pure () - Just (CChatItem SMDSnd ci) -> do - chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId (chatItemId' ci) CISSndSent - toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) - _ -> pure () + updateDirectItemStatus ct conn msgId $ CISSndSent SSPComplete SWITCH qd phase cStats -> do toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) when (phase `elem` [SPStarted, SPCompleted]) $ case qd of @@ -2915,10 +2930,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId MERR msgId err -> do - chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId - forM_ chatItemId_ $ \chatItemId -> do - chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId chatItemId (agentErrToItemStatus err) - toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + updateDirectItemStatus ct conn msgId $ agentErrToItemStatus err toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err ERR err -> do @@ -3064,7 +3076,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XGrpInfo p' -> xGrpInfo gInfo m p' msg msgMeta BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) - pure False -- no receipts in group now $ hasDeliveryReceipt $ toCMEventTag event + currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo + let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo + pure $ + fromMaybe (sendRcptsSmallGroups user) sendRcpts + && hasDeliveryReceipt (toCMEventTag event) + && currentMemCount <= smallGroupsRcptsMemLimit where canSend a | memberRole (m :: GroupMember) <= GRObserver = messageError "member is not allowed to send messages" @@ -3075,6 +3092,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do SENT msgId -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId + updateGroupItemStatus gInfo m conn msgId $ CISSndSent SSPComplete SWITCH qd phase cStats -> do toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) when (phase `elem` [SPStarted, SPCompleted]) $ case qd of @@ -3111,7 +3129,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId - MERR _ err -> do + MERR msgId err -> do + chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId + forM_ chatItemId_ $ \itemId -> do + let GroupMember {groupMemberId} = m + updateGroupMemSndStatus itemId groupMemberId $ agentErrToItemStatus err -- group errors are silenced to reduce load on UI event log -- toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err @@ -3366,7 +3388,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- 1) retry processing several times -- 2) stabilize database -- 3) show screen of death to the user asking to restart - -- TODO send receipt depending on contact/group settings tryChatError action >>= \case Right withRcpt -> ack $ if withRcpt then Just "" else Nothing Left e -> ack Nothing >> throwError e @@ -4293,21 +4314,52 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createGroupFeatureChangedItems user cd CIRcvGroupFeature g g' directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () - directMsgReceived ct@Contact {contactId} Connection {connId} msgMeta msgRcpts = do + directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus - withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId agentMsgId) >>= \case - Just (CChatItem SMDSnd ci) -> do - chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId (chatItemId' ci) $ CISSndRcvd msgRcptStatus - toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) - _ -> pure () + updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () - groupMsgReceived gInfo m Connection {connId} msgMeta msgRcpts = do + groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta - forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> + forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore $ \db -> createSndMsgDeliveryEvent db connId agentMsgId $ MDSSndRcvd msgRcptStatus + updateGroupItemStatus gInfo m conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete + + updateDirectItemStatus :: Contact -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> m () + updateDirectItemStatus ct@Contact {contactId} Connection {connId} msgId newStatus = + withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId msgId) >>= \case + Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ _}}) -> pure () + Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) + | itemStatus == newStatus -> pure () + | otherwise -> do + chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId itemId newStatus + toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + _ -> pure () + + updateGroupMemSndStatus :: ChatItemId -> GroupMemberId -> CIStatus 'MDSnd -> m Bool + updateGroupMemSndStatus itemId groupMemberId newStatus = + runExceptT (withStore $ \db -> getGroupSndStatus db itemId groupMemberId) >>= \case + Right (CISSndRcvd _ _) -> pure False + Right memStatus + | memStatus == newStatus -> pure False + | otherwise -> withStore' (\db -> updateGroupSndStatus db itemId groupMemberId newStatus) $> True + _ -> pure False + + updateGroupItemStatus :: GroupInfo -> GroupMember -> Connection -> AgentMsgId -> CIStatus 'MDSnd -> m () + updateGroupItemStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus = + withStore' (\db -> getGroupChatItemByAgentMsgId db user groupId connId msgId) >>= \case + Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ SSPComplete}}) -> pure () + Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) -> do + memStatusChanged <- updateGroupMemSndStatus itemId groupMemberId newMemStatus + when memStatusChanged $ do + memStatusCounts <- withStore' (`getGroupSndStatusCounts` itemId) + let newStatus = membersGroupItemStatus memStatusCounts + when (newStatus /= itemStatus) $ do + chatItem <- withStore $ \db -> updateGroupChatItemStatus db user groupId itemId newStatus + toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) + _ -> pure () parseFileDescription :: (ChatMonad m, FilePartyI p) => Text -> m (ValidFileDescription p) parseFileDescription = @@ -4523,26 +4575,33 @@ deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do (Just $ "createSndMsgDelivery, sndMsgDelivery: " <> show sndMsgDelivery <> ", msgId: " <> show msgId <> ", cmEventTag: " <> show cmEventTag <> ", msgDeliveryStatus: MDSSndAgent") $ \db -> createSndMsgDelivery db sndMsgDelivery msgId -sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m SndMessage +sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = sendGroupMessage' user members chatMsgEvent groupId Nothing $ pure () -sendGroupMessage' :: (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m SndMessage +sendGroupMessage' :: forall e m. (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m (SndMessage, [GroupMember]) sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) -- TODO collect failed deliveries into a single error - forM_ (filter memberCurrent members) $ \m -> - messageMember m msg `catchChatError` (toView . CRChatError (Just user)) - pure msg + rs <- forM (filter memberCurrent members) $ \m -> + messageMember m msg `catchChatError` (\e -> toView (CRChatError (Just user) e) $> Nothing) + let sentToMembers = catMaybes rs + pure (msg, sentToMembers) where + messageMember :: GroupMember -> SndMessage -> m (Maybe GroupMember) messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of - Nothing -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + Nothing -> do + withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + pure $ Just m Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted -> pure () + | connDisabled conn || connStatus == ConnDeleted -> pure Nothing | connStatus == ConnSndReady || connStatus == ConnReady -> do let tag = toCMEventTag chatMsgEvent deliverMessage conn tag msgBody msgId >> postDeliver - | otherwise -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + pure $ Just m + | otherwise -> do + withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + pure $ Just m sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do @@ -4924,8 +4983,10 @@ chatCommandP = "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), ("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)), "/set receipts all " *> (SetAllContactReceipts <$> onOffP), - "/_set receipts " *> (APISetUserContactReceipts <$> A.decimal <* A.space <*> receiptSettings), - "/set receipts " *> (SetUserContactReceipts <$> receiptSettings), + "/_set receipts contacts " *> (APISetUserContactReceipts <$> A.decimal <* A.space <*> receiptSettings), + "/set receipts contacts " *> (SetUserContactReceipts <$> receiptSettings), + "/_set receipts groups " *> (APISetUserGroupReceipts <$> A.decimal <* A.space <*> receiptSettings), + "/set receipts groups " *> (SetUserGroupReceipts <$> receiptSettings), "/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP), "/_unhide user " *> (APIUnhideUser <$> A.decimal <* A.space <*> jsonP), "/_mute user " *> (APIMuteUser <$> A.decimal), diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 4483e701b8..7a738512bd 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -21,7 +21,8 @@ import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Chat.Types (Contact, ContactId, User, decodeJSON, encodeJSON) +import Simplex.Chat.Types (Contact, ContactId, User) +import Simplex.Chat.Types.Util (decodeJSON, encodeJSON) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 467857a86a..90f90fdcb6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -47,6 +47,7 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Client (AgentLocks, ProtocolTestFailure) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) @@ -202,6 +203,8 @@ data ChatCommand | SetAllContactReceipts Bool | APISetUserContactReceipts UserId UserMsgReceiptSettings | SetUserContactReceipts UserMsgReceiptSettings + | APISetUserGroupReceipts UserId UserMsgReceiptSettings + | SetUserGroupReceipts UserMsgReceiptSettings | APIHideUser UserId UserPwd | APIUnhideUser UserId UserPwd | APIMuteUser UserId diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index b0beccb86b..1393831630 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -28,6 +28,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import GHC.Generics import Simplex.Chat.Types +import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 23baa618ab..daf59d82ac 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -21,7 +21,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) -import Data.Maybe (isJust, isNothing) +import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -35,6 +35,7 @@ import Simplex.Chat.Markdown import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) @@ -623,13 +624,15 @@ data CIFileInfo = CIFileInfo data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd - CISSndSent :: CIStatus 'MDSnd - CISSndRcvd :: MsgReceiptStatus -> CIStatus 'MDSnd + CISSndSent :: SndCIStatusProgress -> CIStatus 'MDSnd + CISSndRcvd :: MsgReceiptStatus -> SndCIStatusProgress -> CIStatus 'MDSnd CISSndErrorAuth :: CIStatus 'MDSnd CISSndError :: String -> CIStatus 'MDSnd CISRcvNew :: CIStatus 'MDRcv CISRcvRead :: CIStatus 'MDRcv +deriving instance Eq (CIStatus d) + deriving instance Show (CIStatus d) instance ToJSON (CIStatus d) where @@ -638,6 +641,8 @@ instance ToJSON (CIStatus d) where instance MsgDirectionI d => ToField (CIStatus d) where toField = toField . decodeLatin1 . strEncode +instance (Typeable d, MsgDirectionI d) => FromField (CIStatus d) where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d) @@ -647,8 +652,8 @@ deriving instance Show ACIStatus instance MsgDirectionI d => StrEncoding (CIStatus d) where strEncode = \case CISSndNew -> "snd_new" - CISSndSent -> "snd_sent" - CISSndRcvd status -> "snd_rcvd " <> strEncode status + CISSndSent sndProgress -> "snd_sent " <> strEncode sndProgress + CISSndRcvd msgRcptStatus sndProgress -> "snd_rcvd " <> strEncode msgRcptStatus <> " " <> strEncode sndProgress CISSndErrorAuth -> "snd_error_auth" CISSndError e -> "snd_error " <> encodeUtf8 (T.pack e) CISRcvNew -> "rcv_new" @@ -660,8 +665,8 @@ instance StrEncoding ACIStatus where strP = A.takeTill (== ' ') >>= \case "snd_new" -> pure $ ACIStatus SMDSnd CISSndNew - "snd_sent" -> pure $ ACIStatus SMDSnd CISSndSent - "snd_rcvd" -> ACIStatus SMDSnd . CISSndRcvd <$> (A.space *> strP) + "snd_sent" -> ACIStatus SMDSnd . CISSndSent <$> ((A.space *> strP) <|> pure SSPComplete) + "snd_rcvd" -> ACIStatus SMDSnd <$> (CISSndRcvd <$> (A.space *> strP) <*> ((A.space *> strP) <|> pure SSPComplete)) "snd_error_auth" -> pure $ ACIStatus SMDSnd CISSndErrorAuth "snd_error" -> ACIStatus SMDSnd . CISSndError . T.unpack . safeDecodeUtf8 <$> (A.space *> A.takeByteString) "rcv_new" -> pure $ ACIStatus SMDRcv CISRcvNew @@ -670,8 +675,8 @@ instance StrEncoding ACIStatus where data JSONCIStatus = JCISSndNew - | JCISSndSent - | JCISSndRcvd {msgRcptStatus :: MsgReceiptStatus} + | JCISSndSent {sndProgress :: SndCIStatusProgress} + | JCISSndRcvd {msgRcptStatus :: MsgReceiptStatus, sndProgress :: SndCIStatusProgress} | JCISSndErrorAuth | JCISSndError {agentError :: String} | JCISRcvNew @@ -685,8 +690,8 @@ instance ToJSON JSONCIStatus where jsonCIStatus :: CIStatus d -> JSONCIStatus jsonCIStatus = \case CISSndNew -> JCISSndNew - CISSndSent -> JCISSndSent - CISSndRcvd ok -> JCISSndRcvd ok + CISSndSent sndProgress -> JCISSndSent sndProgress + CISSndRcvd msgRcptStatus sndProgress -> JCISSndRcvd msgRcptStatus sndProgress CISSndErrorAuth -> JCISSndErrorAuth CISSndError e -> JCISSndError e CISRcvNew -> JCISRcvNew @@ -702,6 +707,40 @@ ciCreateStatus content = case msgDirection @d of SMDSnd -> ciStatusNew SMDRcv -> if ciRequiresAttention content then ciStatusNew else CISRcvRead +membersGroupItemStatus :: [(CIStatus 'MDSnd, Int)] -> CIStatus 'MDSnd +membersGroupItemStatus memStatusCounts + | rcvdOk == total = CISSndRcvd MROk SSPComplete + | rcvdOk + rcvdBad == total = CISSndRcvd MRBadMsgHash SSPComplete + | rcvdBad > 0 = CISSndRcvd MRBadMsgHash SSPPartial + | rcvdOk > 0 = CISSndRcvd MROk SSPPartial + | sent == total = CISSndSent SSPComplete + | sent > 0 = CISSndSent SSPPartial + | otherwise = CISSndNew + where + total = sum $ map snd memStatusCounts + rcvdOk = fromMaybe 0 $ lookup (CISSndRcvd MROk SSPComplete) memStatusCounts + rcvdBad = fromMaybe 0 $ lookup (CISSndRcvd MRBadMsgHash SSPComplete) memStatusCounts + sent = fromMaybe 0 $ lookup (CISSndSent SSPComplete) memStatusCounts + +data SndCIStatusProgress + = SSPPartial + | SSPComplete + deriving (Eq, Show, Generic) + +instance ToJSON SndCIStatusProgress where + toJSON = J.genericToJSON . enumJSON $ dropPrefix "SSP" + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "SSP" + +instance StrEncoding SndCIStatusProgress where + strEncode = \case + SSPPartial -> "partial" + SSPComplete -> "complete" + strP = + A.takeWhile1 (/= ' ') >>= \case + "partial" -> pure SSPPartial + "complete" -> pure SSPComplete + _ -> fail "bad SndCIStatusProgress" + type ChatItemId = Int64 type ChatItemTs = UTCTime @@ -886,7 +925,8 @@ itemDeletedTs = \case CIModerated ts _ -> ts data ChatItemInfo = ChatItemInfo - { itemVersions :: [ChatItemVersion] + { itemVersions :: [ChatItemVersion], + memberDeliveryStatuses :: Maybe [MemberDeliveryStatus] } deriving (Eq, Show, Generic) @@ -916,6 +956,14 @@ mkItemVersion ChatItem {content, meta} = version <$> ciMsgContent content createdAt = createdAt } +data MemberDeliveryStatus = MemberDeliveryStatus + { groupMemberId :: GroupMemberId, + memberDeliveryStatus :: CIStatus 'MDSnd + } + deriving (Eq, Show, Generic) + +instance ToJSON MemberDeliveryStatus where toEncoding = J.genericToEncoding J.defaultOptions + data CIModeration = CIModeration { moderationId :: Int64, moderatorMember :: GroupMember, diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 40b139783b..725cf74cf9 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -29,6 +29,8 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Protocol import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, singleFieldJSON, sumTypeJSON) diff --git a/src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs b/src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs new file mode 100644 index 0000000000..8453da88f5 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230721_group_snd_item_statuses.hs @@ -0,0 +1,33 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230721_group_snd_item_statuses where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230721_group_snd_item_statuses :: Query +m20230721_group_snd_item_statuses = + [sql| +CREATE TABLE group_snd_item_statuses ( + group_snd_item_status_id INTEGER PRIMARY KEY, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + group_snd_item_status TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON group_snd_item_statuses(chat_item_id); +CREATE INDEX idx_group_snd_item_statuses_group_member_id ON group_snd_item_statuses(group_member_id); + +UPDATE users SET send_rcpts_small_groups = 1 WHERE send_rcpts_contacts = 1; +|] + +down_m20230721_group_snd_item_statuses :: Query +down_m20230721_group_snd_item_statuses = + [sql| +DROP INDEX idx_group_snd_item_statuses_group_member_id; +DROP INDEX idx_group_snd_item_statuses_chat_item_id; + +DROP TABLE group_snd_item_statuses; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 893d00d345..f925726436 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -496,6 +496,14 @@ CREATE TABLE chat_item_moderations( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); +CREATE TABLE group_snd_item_statuses( + group_snd_item_status_id INTEGER PRIMARY KEY, + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + group_snd_item_status TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -687,3 +695,9 @@ CREATE INDEX idx_chat_item_moderations_group ON chat_item_moderations( item_member_id, shared_msg_id ); +CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON group_snd_item_statuses( + chat_item_id +); +CREATE INDEX idx_group_snd_item_statuses_group_member_id ON group_snd_item_statuses( + group_member_id +); diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 62f8055da6..31d1eb5738 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -41,6 +41,7 @@ import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Call import Simplex.Chat.Types +import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 416986173c..4b66291fe9 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -23,6 +23,7 @@ import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Shared import Simplex.Chat.Protocol import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow') getConnectionEntity :: DB.Connection -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index da03ed3a7c..944527ae48 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -69,6 +69,7 @@ import qualified Database.SQLite.Simple as DB import Database.SQLite.Simple.QQ (sql) import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, InvitationId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c622f3e87b..c3c62d52da 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -39,6 +39,7 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMembers, getGroupMembersForExpiration, + getGroupCurrentMembersCount, deleteGroupConnectionsAndFiles, deleteGroupItemsAndMembers, deleteGroup, @@ -97,6 +98,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Crypto as C @@ -547,6 +549,20 @@ toContactMember :: User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember toContactMember User {userContactId} (memberRow :. connRow) = (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection connRow} +getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int +getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do + statuses :: [GroupMemberStatus] <- + map fromOnly + <$> DB.query + db + [sql| + SELECT member_status + FROM group_members + WHERE group_id = ? AND user_id = ? + |] + (groupId, userId) + pure $ length $ filter memberCurrent' statuses + getGroupInvitation :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation getGroupInvitation db user groupId = getConnRec_ user >>= \case diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 29e6e2abc3..b185b3a972 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -44,6 +44,7 @@ module Simplex.Chat.Store.Messages createChatItemVersion, deleteDirectChatItem, markDirectChatItemDeleted, + updateGroupChatItemStatus, updateGroupChatItem, deleteGroupChatItem, updateGroupChatItemModerated, @@ -69,6 +70,7 @@ module Simplex.Chat.Store.Messages getGroupChatItem, getGroupChatItemBySharedMsgId, getGroupMemberCIBySharedMsgId, + getGroupChatItemByAgentMsgId, getGroupMemberChatItemLast, getDirectChatItemIdByText, getDirectChatItemIdByText', @@ -87,6 +89,11 @@ module Simplex.Chat.Store.Messages createCIModeration, getCIModeration, deleteCIModeration, + createGroupSndStatus, + getGroupSndStatus, + updateGroupSndStatus, + getGroupSndStatuses, + getGroupSndStatusCounts, ) where @@ -1325,6 +1332,16 @@ getDirectChatItemIdByText' db User {userId} contactId msg = |] (userId, contactId, msg <> "%") +updateGroupChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupId -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d) +updateGroupChatItemStatus db user@User {userId} groupId itemId itemStatus = do + ci <- liftEither . correctDir =<< getGroupChatItem db user groupId itemId + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, groupId, itemId) + pure ci {meta = (meta ci) {itemStatus}} + where + correctDir :: CChatItem c -> Either StoreError (ChatItem c d) + correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci + updateGroupChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) updateGroupChatItem db user groupId ci newContent live msgId_ = do currentTs <- liftIO getCurrentTime @@ -1434,6 +1451,11 @@ getGroupMemberCIBySharedMsgId db user@User {userId} groupId memberId sharedMsgId (GCUserMember, userId, groupId, memberId, sharedMsgId) getGroupChatItem db user groupId itemId +getGroupChatItemByAgentMsgId :: DB.Connection -> User -> GroupId -> Int64 -> AgentMsgId -> IO (Maybe (CChatItem 'CTGroup)) +getGroupChatItemByAgentMsgId db user groupId connId msgId = do + itemId_ <- getChatItemIdByAgentMsgId db connId msgId + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupChatItem db user groupId) itemId_ + getGroupChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do currentTs <- getCurrentTime @@ -1847,3 +1869,58 @@ deleteCIModeration db GroupInfo {groupId} itemMemberId (Just sharedMsgId) = db "DELETE FROM chat_item_moderations WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ?" (groupId, itemMemberId, sharedMsgId) + +createGroupSndStatus :: DB.Connection -> ChatItemId -> GroupMemberId -> CIStatus 'MDSnd -> IO () +createGroupSndStatus db itemId memberId status = + DB.execute + db + "INSERT INTO group_snd_item_statuses (chat_item_id, group_member_id, group_snd_item_status) VALUES (?,?,?)" + (itemId, memberId, status) + +getGroupSndStatus :: DB.Connection -> ChatItemId -> GroupMemberId -> ExceptT StoreError IO (CIStatus 'MDSnd) +getGroupSndStatus db itemId memberId = + ExceptT . firstRow fromOnly (SENoGroupSndStatus itemId memberId) $ + DB.query + db + [sql| + SELECT group_snd_item_status + FROM group_snd_item_statuses + WHERE chat_item_id = ? AND group_member_id = ? + LIMIT 1 + |] + (itemId, memberId) + +updateGroupSndStatus :: DB.Connection -> ChatItemId -> GroupMemberId -> CIStatus 'MDSnd -> IO () +updateGroupSndStatus db itemId memberId status = do + currentTs <- liftIO getCurrentTime + DB.execute + db + [sql| + UPDATE group_snd_item_statuses + SET group_snd_item_status = ?, updated_at = ? + WHERE chat_item_id = ? AND group_member_id = ? + |] + (status, currentTs, itemId, memberId) + +getGroupSndStatuses :: DB.Connection -> ChatItemId -> IO [(GroupMemberId, CIStatus 'MDSnd)] +getGroupSndStatuses db itemId = + DB.query + db + [sql| + SELECT group_member_id, group_snd_item_status + FROM group_snd_item_statuses + WHERE chat_item_id = ? + |] + (Only itemId) + +getGroupSndStatusCounts :: DB.Connection -> ChatItemId -> IO [(CIStatus 'MDSnd, Int)] +getGroupSndStatusCounts db itemId = + DB.query + db + [sql| + SELECT group_snd_item_status, COUNT(1) + FROM group_snd_item_statuses + WHERE chat_item_id = ? + GROUP BY group_snd_item_status + |] + (Only itemId) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index f1fd40d438..972f0718eb 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -74,6 +74,7 @@ import Simplex.Chat.Migrations.M20230608_deleted_contacts import Simplex.Chat.Migrations.M20230618_favorite_chats import Simplex.Chat.Migrations.M20230621_chat_item_moderations import Simplex.Chat.Migrations.M20230705_delivery_receipts +import Simplex.Chat.Migrations.M20230721_group_snd_item_statuses import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -147,7 +148,8 @@ schemaMigrations = ("20230608_deleted_contacts", m20230608_deleted_contacts, Just down_m20230608_deleted_contacts), ("20230618_favorite_chats", m20230618_favorite_chats, Just down_m20230618_favorite_chats), ("20230621_chat_item_moderations", m20230621_chat_item_moderations, Just down_m20230621_chat_item_moderations), - ("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts) + ("20230705_delivery_receipts", m20230705_delivery_receipts, Just down_m20230705_delivery_receipts), + ("20230721_group_snd_item_statuses", m20230721_group_snd_item_statuses, Just down_m20230721_group_snd_item_statuses) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 797f237f79..ddbe665d71 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -30,6 +30,7 @@ module Simplex.Chat.Store.Profiles updateUserPrivacy, updateAllContactReceipts, updateUserContactReceipts, + updateUserGroupReceipts, updateUserProfile, setUserProfileContactLink, getUserContactProfiles, @@ -74,6 +75,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Crypto as C @@ -91,7 +93,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image, when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" let showNtfs = True sendRcptsContacts = True - sendRcptsSmallGroups = False + sendRcptsSmallGroups = True DB.execute db "INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,0,?,?,?,?,?)" @@ -221,13 +223,21 @@ updateUserPrivacy db User {userId, showNtfs, viewPwdHash} = updateAllContactReceipts :: DB.Connection -> Bool -> IO () updateAllContactReceipts db onOff = - DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE view_pwd_hash IS NULL" (Only onOff) + DB.execute + db + "UPDATE users SET send_rcpts_contacts = ?, send_rcpts_small_groups = ? WHERE view_pwd_hash IS NULL" + (onOff, onOff) updateUserContactReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO () updateUserContactReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do DB.execute db "UPDATE users SET send_rcpts_contacts = ? WHERE user_id = ?" (enable, userId) when clearOverrides $ DB.execute_ db "UPDATE contacts SET send_rcpts = NULL" +updateUserGroupReceipts :: DB.Connection -> User -> UserMsgReceiptSettings -> IO () +updateUserGroupReceipts db User {userId} UserMsgReceiptSettings {enable, clearOverrides} = do + DB.execute db "UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ?" (enable, userId) + when clearOverrides $ DB.execute_ db "UPDATE groups SET send_rcpts = NULL" + updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = do diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index fc1444a8a9..9e4d3c0e02 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -31,6 +31,7 @@ import GHC.Generics (Generic) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) @@ -91,6 +92,7 @@ data StoreError | SEGroupLinkNotFound {groupInfo :: GroupInfo} | SEHostMemberIdNotFound {groupId :: Int64} | SEContactNotFoundByFileId {fileId :: FileTransferId} + | SENoGroupSndStatus {itemId :: ChatItemId, groupMemberId :: GroupMemberId} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 1ddf3c3845..9d9791f1a9 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -13,9 +13,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE StrictData #-} -{-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilyDependencies #-} {-# LANGUAGE UndecidableInstances #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} @@ -24,7 +22,6 @@ module Simplex.Chat.Types where -import Control.Applicative ((<|>)) import Crypto.Number.Serialize (os2ip) import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J @@ -33,27 +30,22 @@ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString, pack, unpack) import qualified Data.ByteString.Char8 as B -import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) -import Data.Maybe (fromMaybe, isJust) +import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) -import Data.Typeable -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (FieldParser, FromField (..), returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok (Ok (Ok)) +import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import GHC.Records.Compat +import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) -import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) +import Simplex.Messaging.Util ((<$?>)) class IsContact a where contactId' :: a -> ContactId @@ -353,659 +345,14 @@ defaultChatSettings = ChatSettings pattern DisableNtfs :: ChatSettings pattern DisableNtfs <- ChatSettings {enableNtfs = False} -data ChatFeature - = CFTimedMessages - | CFFullDelete - | CFReactions - | CFVoice - | CFCalls - deriving (Show, Generic) - -data SChatFeature (f :: ChatFeature) where - SCFTimedMessages :: SChatFeature 'CFTimedMessages - SCFFullDelete :: SChatFeature 'CFFullDelete - SCFReactions :: SChatFeature 'CFReactions - SCFVoice :: SChatFeature 'CFVoice - SCFCalls :: SChatFeature 'CFCalls - -deriving instance Show (SChatFeature f) - -data AChatFeature = forall f. FeatureI f => ACF (SChatFeature f) - -deriving instance Show AChatFeature - -chatFeatureNameText :: ChatFeature -> Text -chatFeatureNameText = \case - CFTimedMessages -> "Disappearing messages" - CFFullDelete -> "Full deletion" - CFReactions -> "Message reactions" - CFVoice -> "Voice messages" - CFCalls -> "Audio/video calls" - -chatFeatureNameText' :: SChatFeature f -> Text -chatFeatureNameText' = chatFeatureNameText . chatFeature - featureAllowed :: SChatFeature f -> (PrefEnabled -> Bool) -> Contact -> Bool featureAllowed feature forWhom Contact {mergedPreferences} = let ContactUserPreference {enabled} = getContactUserPreference feature mergedPreferences in forWhom enabled -instance ToJSON ChatFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "CF" - -instance FromJSON ChatFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CF" - -allChatFeatures :: [AChatFeature] -allChatFeatures = - [ ACF SCFTimedMessages, - ACF SCFFullDelete, - ACF SCFReactions, - ACF SCFVoice, - ACF SCFCalls - ] - -chatPrefSel :: SChatFeature f -> Preferences -> Maybe (FeaturePreference f) -chatPrefSel = \case - SCFTimedMessages -> timedMessages - SCFFullDelete -> fullDelete - SCFReactions -> reactions - SCFVoice -> voice - SCFCalls -> calls - -chatFeature :: SChatFeature f -> ChatFeature -chatFeature = \case - SCFTimedMessages -> CFTimedMessages - SCFFullDelete -> CFFullDelete - SCFReactions -> CFReactions - SCFVoice -> CFVoice - SCFCalls -> CFCalls - -class PreferenceI p where - getPreference :: SChatFeature f -> p -> FeaturePreference f - -instance PreferenceI Preferences where - getPreference f prefs = fromMaybe (getPreference f defaultChatPrefs) (chatPrefSel f prefs) - -instance PreferenceI (Maybe Preferences) where - getPreference f prefs = fromMaybe (getPreference f defaultChatPrefs) (chatPrefSel f =<< prefs) - -instance PreferenceI FullPreferences where - getPreference = \case - SCFTimedMessages -> timedMessages - SCFFullDelete -> fullDelete - SCFReactions -> reactions - SCFVoice -> voice - SCFCalls -> calls - {-# INLINE getPreference #-} - -setPreference :: forall f. FeatureI f => SChatFeature f -> Maybe FeatureAllowed -> Maybe Preferences -> Preferences -setPreference f allow_ prefs_ = setPreference_ f pref $ fromMaybe emptyChatPrefs prefs_ - where - pref = setAllow <$> allow_ - setAllow :: FeatureAllowed -> FeaturePreference f - setAllow = setField @"allow" (getPreference f prefs) - prefs = mergePreferences Nothing prefs_ - -setPreference' :: SChatFeature f -> Maybe (FeaturePreference f) -> Maybe Preferences -> Preferences -setPreference' f pref_ prefs_ = setPreference_ f pref_ $ fromMaybe emptyChatPrefs prefs_ - -setPreference_ :: SChatFeature f -> Maybe (FeaturePreference f) -> Preferences -> Preferences -setPreference_ f pref_ prefs = - case f of - SCFTimedMessages -> prefs {timedMessages = pref_} - SCFFullDelete -> prefs {fullDelete = pref_} - SCFReactions -> prefs {reactions = pref_} - SCFVoice -> prefs {voice = pref_} - SCFCalls -> prefs {calls = pref_} - --- collection of optional chat preferences for the user and the contact -data Preferences = Preferences - { timedMessages :: Maybe TimedMessagesPreference, - fullDelete :: Maybe FullDeletePreference, - reactions :: Maybe ReactionsPreference, - voice :: Maybe VoicePreference, - calls :: Maybe CallsPreference - } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON Preferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField Preferences where - toField = toField . encodeJSON - -instance FromField Preferences where - fromField = fromTextField_ decodeJSON - -data GroupFeature - = GFTimedMessages - | GFDirectMessages - | GFFullDelete - | GFReactions - | GFVoice - | GFFiles - deriving (Show, Generic) - -data SGroupFeature (f :: GroupFeature) where - SGFTimedMessages :: SGroupFeature 'GFTimedMessages - SGFDirectMessages :: SGroupFeature 'GFDirectMessages - SGFFullDelete :: SGroupFeature 'GFFullDelete - SGFReactions :: SGroupFeature 'GFReactions - SGFVoice :: SGroupFeature 'GFVoice - SGFFiles :: SGroupFeature 'GFFiles - -deriving instance Show (SGroupFeature f) - -data AGroupFeature = forall f. GroupFeatureI f => AGF (SGroupFeature f) - -deriving instance Show AGroupFeature - -groupFeatureNameText :: GroupFeature -> Text -groupFeatureNameText = \case - GFTimedMessages -> "Disappearing messages" - GFDirectMessages -> "Direct messages" - GFFullDelete -> "Full deletion" - GFReactions -> "Message reactions" - GFVoice -> "Voice messages" - GFFiles -> "Files and media" - -groupFeatureNameText' :: SGroupFeature f -> Text -groupFeatureNameText' = groupFeatureNameText . toGroupFeature - groupFeatureAllowed :: GroupFeatureI f => SGroupFeature f -> GroupInfo -> Bool groupFeatureAllowed feature gInfo = groupFeatureAllowed' feature $ fullGroupPreferences gInfo -groupFeatureAllowed' :: GroupFeatureI f => SGroupFeature f -> FullGroupPreferences -> Bool -groupFeatureAllowed' feature prefs = - getField @"enable" (getGroupPreference feature prefs) == FEOn - -instance ToJSON GroupFeature where - toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "GF" - toJSON = J.genericToJSON . enumJSON $ dropPrefix "GF" - -instance FromJSON GroupFeature where - parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "GF" - -allGroupFeatures :: [AGroupFeature] -allGroupFeatures = - [ AGF SGFTimedMessages, - AGF SGFDirectMessages, - AGF SGFFullDelete, - AGF SGFReactions, - AGF SGFVoice, - AGF SGFFiles - ] - -groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel = \case - SGFTimedMessages -> timedMessages - SGFDirectMessages -> directMessages - SGFFullDelete -> fullDelete - SGFReactions -> reactions - SGFVoice -> voice - SGFFiles -> files - -toGroupFeature :: SGroupFeature f -> GroupFeature -toGroupFeature = \case - SGFTimedMessages -> GFTimedMessages - SGFDirectMessages -> GFDirectMessages - SGFFullDelete -> GFFullDelete - SGFReactions -> GFReactions - SGFVoice -> GFVoice - SGFFiles -> GFFiles - -class GroupPreferenceI p where - getGroupPreference :: SGroupFeature f -> p -> GroupFeaturePreference f - -instance GroupPreferenceI GroupPreferences where - getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt prefs) - -instance GroupPreferenceI (Maybe GroupPreferences) where - getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) - -instance GroupPreferenceI FullGroupPreferences where - getGroupPreference = \case - SGFTimedMessages -> timedMessages - SGFDirectMessages -> directMessages - SGFFullDelete -> fullDelete - SGFReactions -> reactions - SGFVoice -> voice - SGFFiles -> files - {-# INLINE getGroupPreference #-} - --- collection of optional group preferences -data GroupPreferences = GroupPreferences - { timedMessages :: Maybe TimedMessagesGroupPreference, - directMessages :: Maybe DirectMessagesGroupPreference, - fullDelete :: Maybe FullDeleteGroupPreference, - reactions :: Maybe ReactionsGroupPreference, - voice :: Maybe VoiceGroupPreference, - files :: Maybe FilesGroupPreference - } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreferences where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -instance ToField GroupPreferences where - toField = toField . encodeJSON - -instance FromField GroupPreferences where - fromField = fromTextField_ decodeJSON - -setGroupPreference :: forall f. GroupFeatureI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupPreferences -> GroupPreferences -setGroupPreference f enable prefs_ = setGroupPreference_ f pref prefs - where - prefs = mergeGroupPreferences prefs_ - pref :: GroupFeaturePreference f - pref = setField @"enable" (getGroupPreference f prefs) enable - -setGroupPreference' :: SGroupFeature f -> GroupFeaturePreference f -> Maybe GroupPreferences -> GroupPreferences -setGroupPreference' f pref prefs_ = setGroupPreference_ f pref prefs - where - prefs = mergeGroupPreferences prefs_ - -setGroupPreference_ :: SGroupFeature f -> GroupFeaturePreference f -> FullGroupPreferences -> GroupPreferences -setGroupPreference_ f pref prefs = - toGroupPreferences $ case f of - SGFTimedMessages -> prefs {timedMessages = pref} - SGFDirectMessages -> prefs {directMessages = pref} - SGFFullDelete -> prefs {fullDelete = pref} - SGFReactions -> prefs {reactions = pref} - SGFVoice -> prefs {voice = pref} - SGFFiles -> prefs {files = pref} - -setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences -setGroupTimedMessagesPreference pref prefs_ = - toGroupPreferences $ prefs {timedMessages = pref} - where - prefs = mergeGroupPreferences prefs_ - --- full collection of chat preferences defined in the app - it is used to ensure we include all preferences and to simplify processing --- if some of the preferences are not defined in Preferences, defaults from defaultChatPrefs are used here. -data FullPreferences = FullPreferences - { timedMessages :: TimedMessagesPreference, - fullDelete :: FullDeletePreference, - reactions :: ReactionsPreference, - voice :: VoicePreference, - calls :: CallsPreference - } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions - --- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing --- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here. -data FullGroupPreferences = FullGroupPreferences - { timedMessages :: TimedMessagesGroupPreference, - directMessages :: DirectMessagesGroupPreference, - fullDelete :: FullDeleteGroupPreference, - reactions :: ReactionsGroupPreference, - voice :: VoiceGroupPreference, - files :: FilesGroupPreference - } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions - --- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences -data ContactUserPreferences = ContactUserPreferences - { timedMessages :: ContactUserPreference TimedMessagesPreference, - fullDelete :: ContactUserPreference FullDeletePreference, - reactions :: ContactUserPreference ReactionsPreference, - voice :: ContactUserPreference VoicePreference, - calls :: ContactUserPreference CallsPreference - } - deriving (Eq, Show, Generic) - -data ContactUserPreference p = ContactUserPreference - { enabled :: PrefEnabled, - userPreference :: ContactUserPref p, - contactPreference :: p - } - deriving (Eq, Show, Generic) - -data ContactUserPref p = CUPContact {preference :: p} | CUPUser {preference :: p} - deriving (Eq, Show, Generic) - -instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON p => ToJSON (ContactUserPreference p) where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON p => ToJSON (ContactUserPref p) where - toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" - toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" - -toChatPrefs :: FullPreferences -> Preferences -toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, calls} = - Preferences - { timedMessages = Just timedMessages, - fullDelete = Just fullDelete, - reactions = Just reactions, - voice = Just voice, - calls = Just calls - } - -defaultChatPrefs :: FullPreferences -defaultChatPrefs = - FullPreferences - { timedMessages = TimedMessagesPreference {allow = FAYes, ttl = Nothing}, - fullDelete = FullDeletePreference {allow = FANo}, - reactions = ReactionsPreference {allow = FAYes}, - voice = VoicePreference {allow = FAYes}, - calls = CallsPreference {allow = FAYes} - } - -emptyChatPrefs :: Preferences -emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing - -defaultGroupPrefs :: FullGroupPreferences -defaultGroupPrefs = - FullGroupPreferences - { timedMessages = TimedMessagesGroupPreference {enable = FEOff, ttl = Just 86400}, - directMessages = DirectMessagesGroupPreference {enable = FEOff}, - fullDelete = FullDeleteGroupPreference {enable = FEOff}, - reactions = ReactionsGroupPreference {enable = FEOn}, - voice = VoiceGroupPreference {enable = FEOn}, - files = FilesGroupPreference {enable = FEOn} - } - -emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing - -data TimedMessagesPreference = TimedMessagesPreference - { allow :: FeatureAllowed, - ttl :: Maybe Int - } - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON TimedMessagesPreference where - toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} - toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} - -data FullDeletePreference = FullDeletePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON FullDeletePreference where toEncoding = J.genericToEncoding J.defaultOptions - -data ReactionsPreference = ReactionsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON ReactionsPreference where toEncoding = J.genericToEncoding J.defaultOptions - -data VoicePreference = VoicePreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON VoicePreference where toEncoding = J.genericToEncoding J.defaultOptions - -data CallsPreference = CallsPreference {allow :: FeatureAllowed} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON CallsPreference where toEncoding = J.genericToEncoding J.defaultOptions - -class (Eq (FeaturePreference f), HasField "allow" (FeaturePreference f) FeatureAllowed) => FeatureI f where - type FeaturePreference (f :: ChatFeature) = p | p -> f - sFeature :: SChatFeature f - prefParam :: FeaturePreference f -> Maybe Int - -instance HasField "allow" TimedMessagesPreference FeatureAllowed where - hasField p = (\allow -> p {allow}, allow (p :: TimedMessagesPreference)) - -instance HasField "allow" FullDeletePreference FeatureAllowed where - hasField p = (\allow -> p {allow}, allow (p :: FullDeletePreference)) - -instance HasField "allow" ReactionsPreference FeatureAllowed where - hasField p = (\allow -> p {allow}, allow (p :: ReactionsPreference)) - -instance HasField "allow" VoicePreference FeatureAllowed where - hasField p = (\allow -> p {allow}, allow (p :: VoicePreference)) - -instance HasField "allow" CallsPreference FeatureAllowed where - hasField p = (\allow -> p {allow}, allow (p :: CallsPreference)) - -instance FeatureI 'CFTimedMessages where - type FeaturePreference 'CFTimedMessages = TimedMessagesPreference - sFeature = SCFTimedMessages - prefParam TimedMessagesPreference {ttl} = ttl - -instance FeatureI 'CFFullDelete where - type FeaturePreference 'CFFullDelete = FullDeletePreference - sFeature = SCFFullDelete - prefParam _ = Nothing - -instance FeatureI 'CFReactions where - type FeaturePreference 'CFReactions = ReactionsPreference - sFeature = SCFReactions - prefParam _ = Nothing - -instance FeatureI 'CFVoice where - type FeaturePreference 'CFVoice = VoicePreference - sFeature = SCFVoice - prefParam _ = Nothing - -instance FeatureI 'CFCalls where - type FeaturePreference 'CFCalls = CallsPreference - sFeature = SCFCalls - prefParam _ = Nothing - -data GroupPreference = GroupPreference - {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -data TimedMessagesGroupPreference = TimedMessagesGroupPreference - { enable :: GroupFeatureEnabled, - ttl :: Maybe Int - } - deriving (Eq, Show, Generic, FromJSON) - -data DirectMessagesGroupPreference = DirectMessagesGroupPreference - {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -data FullDeleteGroupPreference = FullDeleteGroupPreference - {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -data ReactionsGroupPreference = ReactionsGroupPreference - {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -data VoiceGroupPreference = VoiceGroupPreference - {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -data FilesGroupPreference = FilesGroupPreference - {enable :: GroupFeatureEnabled} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON TimedMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON DirectMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON ReactionsGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FullDeleteGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON VoiceGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -instance ToJSON FilesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions - -class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where - type GroupFeaturePreference (f :: GroupFeature) = p | p -> f - sGroupFeature :: SGroupFeature f - groupPrefParam :: GroupFeaturePreference f -> Maybe Int - -instance HasField "enable" GroupPreference GroupFeatureEnabled where - hasField p = (\enable -> p {enable}, enable (p :: GroupPreference)) - -instance HasField "enable" TimedMessagesGroupPreference GroupFeatureEnabled where - hasField p = (\enable -> p {enable}, enable (p :: TimedMessagesGroupPreference)) - -instance HasField "enable" DirectMessagesGroupPreference GroupFeatureEnabled where - hasField p = (\enable -> p {enable}, enable (p :: DirectMessagesGroupPreference)) - -instance HasField "enable" ReactionsGroupPreference GroupFeatureEnabled where - hasField p = (\enable -> p {enable}, enable (p :: ReactionsGroupPreference)) - -instance HasField "enable" FullDeleteGroupPreference GroupFeatureEnabled where - hasField p = (\enable -> p {enable}, enable (p :: FullDeleteGroupPreference)) - -instance HasField "enable" VoiceGroupPreference GroupFeatureEnabled where - hasField p = (\enable -> p {enable}, enable (p :: VoiceGroupPreference)) - -instance HasField "enable" FilesGroupPreference GroupFeatureEnabled where - hasField p = (\enable -> p {enable}, enable (p :: FilesGroupPreference)) - -instance GroupFeatureI 'GFTimedMessages where - type GroupFeaturePreference 'GFTimedMessages = TimedMessagesGroupPreference - sGroupFeature = SGFTimedMessages - groupPrefParam TimedMessagesGroupPreference {ttl} = ttl - -instance GroupFeatureI 'GFDirectMessages where - type GroupFeaturePreference 'GFDirectMessages = DirectMessagesGroupPreference - sGroupFeature = SGFDirectMessages - groupPrefParam _ = Nothing - -instance GroupFeatureI 'GFFullDelete where - type GroupFeaturePreference 'GFFullDelete = FullDeleteGroupPreference - sGroupFeature = SGFFullDelete - groupPrefParam _ = Nothing - -instance GroupFeatureI 'GFReactions where - type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference - sGroupFeature = SGFReactions - groupPrefParam _ = Nothing - -instance GroupFeatureI 'GFVoice where - type GroupFeaturePreference 'GFVoice = VoiceGroupPreference - sGroupFeature = SGFVoice - groupPrefParam _ = Nothing - -instance GroupFeatureI 'GFFiles where - type GroupFeaturePreference 'GFFiles = FilesGroupPreference - sGroupFeature = SGFFiles - groupPrefParam _ = Nothing - -groupPrefStateText :: HasField "enable" p GroupFeatureEnabled => GroupFeature -> p -> Maybe Int -> Text -groupPrefStateText feature pref param = - let enabled = getField @"enable" pref - paramText = if enabled == FEOn then groupParamText_ feature param else "" - in groupFeatureNameText feature <> ": " <> safeDecodeUtf8 (strEncode enabled) <> paramText - -groupParamText_ :: GroupFeature -> Maybe Int -> Text -groupParamText_ feature param = case feature of - GFTimedMessages -> maybe "" (\p -> " (" <> timedTTLText p <> ")") param - _ -> "" - -groupPreferenceText :: forall f. GroupFeatureI f => GroupFeaturePreference f -> Text -groupPreferenceText pref = - let feature = toGroupFeature $ sGroupFeature @f - in groupPrefStateText feature pref $ groupPrefParam pref - -timedTTLText :: Int -> Text -timedTTLText 0 = "0 sec" -timedTTLText ttl = do - let (m', s) = ttl `quotRem` 60 - (h', m) = m' `quotRem` 60 - (d', h) = h' `quotRem` 24 - (mm, d) = d' `quotRem` 30 - T.pack . unwords $ - [mms mm | mm /= 0] <> [ds d | d /= 0] <> [hs h | h /= 0] <> [ms m | m /= 0] <> [ss s | s /= 0] - where - ss s = show s <> " sec" - ms m = show m <> " min" - hs 1 = "1 hour" - hs h = show h <> " hours" - ds 1 = "1 day" - ds 7 = "1 week" - ds 14 = "2 weeks" - ds d = show d <> " days" - mms 1 = "1 month" - mms mm = show mm <> " months" - -toGroupPreference :: GroupFeatureI f => GroupFeaturePreference f -> GroupPreference -toGroupPreference p = GroupPreference {enable = getField @"enable" p} - -data FeatureAllowed - = FAAlways -- allow unconditionally - | FAYes -- allow, if peer allows it - | FANo -- do not allow - deriving (Eq, Show, Generic) - -instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode - -instance ToField FeatureAllowed where toField = toField . strEncode - -instance StrEncoding FeatureAllowed where - strEncode = \case - FAAlways -> "always" - FAYes -> "yes" - FANo -> "no" - strDecode = \case - "always" -> Right FAAlways - "yes" -> Right FAYes - "no" -> Right FANo - r -> Left $ "bad FeatureAllowed " <> B.unpack r - strP = strDecode <$?> A.takeByteString - -instance FromJSON FeatureAllowed where - parseJSON = strParseJSON "FeatureAllowed" - -instance ToJSON FeatureAllowed where - toJSON = strToJSON - toEncoding = strToJEncoding - -data GroupFeatureEnabled = FEOn | FEOff - deriving (Eq, Show, Generic) - -instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode - -instance ToField GroupFeatureEnabled where toField = toField . strEncode - -instance StrEncoding GroupFeatureEnabled where - strEncode = \case - FEOn -> "on" - FEOff -> "off" - strDecode = \case - "on" -> Right FEOn - "off" -> Right FEOff - r -> Left $ "bad GroupFeatureEnabled " <> B.unpack r - strP = strDecode <$?> A.takeByteString - -instance FromJSON GroupFeatureEnabled where - parseJSON = strParseJSON "GroupFeatureEnabled" - -instance ToJSON GroupFeatureEnabled where - toJSON = strToJSON - toEncoding = strToJEncoding - -groupFeatureState :: GroupFeatureI f => GroupFeaturePreference f -> (GroupFeatureEnabled, Maybe Int) -groupFeatureState p = - let enable = getField @"enable" p - param = if enable == FEOn then groupPrefParam p else Nothing - in (enable, param) - -mergePreferences :: Maybe Preferences -> Maybe Preferences -> FullPreferences -mergePreferences contactPrefs userPreferences = - FullPreferences - { timedMessages = pref SCFTimedMessages, - fullDelete = pref SCFFullDelete, - reactions = pref SCFReactions, - voice = pref SCFVoice, - calls = pref SCFCalls - } - where - pref :: SChatFeature f -> FeaturePreference f - pref f = - let sel = chatPrefSel f - in fromMaybe (getPreference f defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel) - mergeUserChatPrefs :: User -> Contact -> FullPreferences mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct) @@ -1014,94 +361,6 @@ mergeUserChatPrefs' user connectedIncognito userPreferences = let userPrefs = if connectedIncognito then Nothing else preferences' user in mergePreferences (Just userPreferences) userPrefs -mergeGroupPreferences :: Maybe GroupPreferences -> FullGroupPreferences -mergeGroupPreferences groupPreferences = - FullGroupPreferences - { timedMessages = pref SGFTimedMessages, - directMessages = pref SGFDirectMessages, - fullDelete = pref SGFFullDelete, - reactions = pref SGFReactions, - voice = pref SGFVoice, - files = pref SGFFiles - } - where - pref :: SGroupFeature f -> GroupFeaturePreference f - pref pt = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPreferences >>= groupPrefSel pt) - -toGroupPreferences :: FullGroupPreferences -> GroupPreferences -toGroupPreferences groupPreferences = - GroupPreferences - { timedMessages = pref SGFTimedMessages, - directMessages = pref SGFDirectMessages, - fullDelete = pref SGFFullDelete, - reactions = pref SGFReactions, - voice = pref SGFVoice, - files = pref SGFFiles - } - where - pref :: SGroupFeature f -> Maybe (GroupFeaturePreference f) - pref f = Just $ getGroupPreference f groupPreferences - -data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool} - deriving (Eq, Show, Generic, FromJSON) - -instance ToJSON PrefEnabled where - toJSON = J.genericToJSON J.defaultOptions - toEncoding = J.genericToEncoding J.defaultOptions - -prefEnabled :: FeatureI f => Bool -> FeaturePreference f -> FeaturePreference f -> PrefEnabled -prefEnabled asymmetric user contact = case (getField @"allow" user, getField @"allow" contact) of - (FAAlways, FANo) -> PrefEnabled {forUser = False, forContact = asymmetric} - (FANo, FAAlways) -> PrefEnabled {forUser = asymmetric, forContact = False} - (_, FANo) -> PrefEnabled False False - (FANo, _) -> PrefEnabled False False - _ -> PrefEnabled True True - -prefStateText :: ChatFeature -> FeatureAllowed -> Maybe Int -> Text -prefStateText feature allowed param = case allowed of - FANo -> "cancelled " <> chatFeatureNameText feature - _ -> "offered " <> chatFeatureNameText feature <> paramText_ feature param - -featureStateText :: ChatFeature -> PrefEnabled -> Maybe Int -> Text -featureStateText feature enabled param = - chatFeatureNameText feature <> ": " <> prefEnabledToText feature enabled param <> case enabled of - PrefEnabled {forUser = True} -> paramText_ feature param - _ -> "" - -paramText_ :: ChatFeature -> Maybe Int -> Text -paramText_ feature param = case feature of - CFTimedMessages -> maybe "" (\p -> " (" <> timedTTLText p <> ")") param - _ -> "" - -prefEnabledToText :: ChatFeature -> PrefEnabled -> Maybe Int -> Text -prefEnabledToText f enabled param = case enabled of - PrefEnabled True True -> enabledStr - PrefEnabled False False -> "off" - PrefEnabled {forUser = True, forContact = False} -> enabledStr <> " for you" - PrefEnabled {forUser = False, forContact = True} -> enabledStr <> " for contact" - where - enabledStr = case f of - CFTimedMessages -> if isJust param then "enabled" else "allowed" - _ -> "enabled" - -preferenceText :: forall f. FeatureI f => FeaturePreference f -> Text -preferenceText p = - let feature = chatFeature $ sFeature @f - allowed = getField @"allow" p - paramText = if allowed == FAAlways || allowed == FAYes then paramText_ feature (prefParam p) else "" - in safeDecodeUtf8 (strEncode allowed) <> paramText - -featureState :: FeatureI f => ContactUserPreference (FeaturePreference f) -> (PrefEnabled, Maybe Int) -featureState ContactUserPreference {enabled, userPreference} = - let param = if forUser enabled then prefParam $ preference userPreference else Nothing - in (enabled, param) - -preferenceState :: FeatureI f => FeaturePreference f -> (FeatureAllowed, Maybe Int) -preferenceState pref = - let allow = getField @"allow" pref - param = if allow == FAAlways || allow == FAYes then prefParam pref else Nothing - in (allow, param) - updateMergedPreferences :: User -> Contact -> Contact updateMergedPreferences user ct = let mergedPreferences = contactUserPreferences user (userPreferences ct) (preferences' ct) (contactConnIncognito ct) @@ -1135,14 +394,6 @@ contactUserPreferences user userPreferences contactPreferences connectedIncognit ctUserPrefs = mergeUserChatPrefs' user connectedIncognito userPreferences ctPrefs = mergePreferences contactPreferences Nothing -getContactUserPreference :: SChatFeature f -> ContactUserPreferences -> ContactUserPreference (FeaturePreference f) -getContactUserPreference = \case - SCFTimedMessages -> timedMessages - SCFFullDelete -> fullDelete - SCFReactions -> reactions - SCFVoice -> voice - SCFCalls -> calls - data Profile = Profile { displayName :: ContactName, fullName :: Text, @@ -1433,14 +684,6 @@ instance ToJSON GroupMemberRole where toJSON = strToJSON toEncoding = strToJEncoding -fromBlobField_ :: Typeable k => (ByteString -> Either String k) -> FieldParser k -fromBlobField_ p = \case - f@(Field (SQLBlob b) _) -> - case p b of - Right k -> Ok k - Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) - f -> returnError ConversionFailed f "expecting SQLBlob column type" - newtype Probe = Probe {unProbe :: ByteString} deriving (Eq, Show) @@ -1539,7 +782,10 @@ memberActive m = case memberStatus m of GSMemCreator -> True memberCurrent :: GroupMember -> Bool -memberCurrent m = case memberStatus m of +memberCurrent = memberCurrent' . memberStatus + +memberCurrent' :: GroupMemberStatus -> Bool +memberCurrent' = \case GSMemRemoved -> False GSMemLeft -> False GSMemGroupDeleted -> False @@ -2194,12 +1440,6 @@ data XGrpMemIntroCont = XGrpMemIntroCont } deriving (Show) -encodeJSON :: ToJSON a => a -> Text -encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode - -decodeJSON :: FromJSON a => Text -> Maybe a -decodeJSON = J.decode . LB.fromStrict . encodeUtf8 - data ServerCfg p = ServerCfg { server :: ProtoServerWithAuth p, preset :: Bool, diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs new file mode 100644 index 0000000000..a89e383242 --- /dev/null +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -0,0 +1,778 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilyDependencies #-} +{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} + +{-# HLINT ignore "Use newtype instead of data" #-} + +module Simplex.Chat.Types.Preferences where + +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson as J +import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Char8 as B +import Data.Maybe (fromMaybe, isJust) +import Data.Text (Text) +import qualified Data.Text as T +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +import GHC.Generics (Generic) +import GHC.Records.Compat +import Simplex.Chat.Types.Util +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, sumTypeJSON) +import Simplex.Messaging.Util (safeDecodeUtf8, (<$?>)) + +data ChatFeature + = CFTimedMessages + | CFFullDelete + | CFReactions + | CFVoice + | CFCalls + deriving (Show, Generic) + +data SChatFeature (f :: ChatFeature) where + SCFTimedMessages :: SChatFeature 'CFTimedMessages + SCFFullDelete :: SChatFeature 'CFFullDelete + SCFReactions :: SChatFeature 'CFReactions + SCFVoice :: SChatFeature 'CFVoice + SCFCalls :: SChatFeature 'CFCalls + +deriving instance Show (SChatFeature f) + +data AChatFeature = forall f. FeatureI f => ACF (SChatFeature f) + +deriving instance Show AChatFeature + +chatFeatureNameText :: ChatFeature -> Text +chatFeatureNameText = \case + CFTimedMessages -> "Disappearing messages" + CFFullDelete -> "Full deletion" + CFReactions -> "Message reactions" + CFVoice -> "Voice messages" + CFCalls -> "Audio/video calls" + +chatFeatureNameText' :: SChatFeature f -> Text +chatFeatureNameText' = chatFeatureNameText . chatFeature + +instance ToJSON ChatFeature where + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "CF" + toJSON = J.genericToJSON . enumJSON $ dropPrefix "CF" + +instance FromJSON ChatFeature where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "CF" + +allChatFeatures :: [AChatFeature] +allChatFeatures = + [ ACF SCFTimedMessages, + ACF SCFFullDelete, + ACF SCFReactions, + ACF SCFVoice, + ACF SCFCalls + ] + +chatPrefSel :: SChatFeature f -> Preferences -> Maybe (FeaturePreference f) +chatPrefSel = \case + SCFTimedMessages -> timedMessages + SCFFullDelete -> fullDelete + SCFReactions -> reactions + SCFVoice -> voice + SCFCalls -> calls + +chatFeature :: SChatFeature f -> ChatFeature +chatFeature = \case + SCFTimedMessages -> CFTimedMessages + SCFFullDelete -> CFFullDelete + SCFReactions -> CFReactions + SCFVoice -> CFVoice + SCFCalls -> CFCalls + +class PreferenceI p where + getPreference :: SChatFeature f -> p -> FeaturePreference f + +instance PreferenceI Preferences where + getPreference f prefs = fromMaybe (getPreference f defaultChatPrefs) (chatPrefSel f prefs) + +instance PreferenceI (Maybe Preferences) where + getPreference f prefs = fromMaybe (getPreference f defaultChatPrefs) (chatPrefSel f =<< prefs) + +instance PreferenceI FullPreferences where + getPreference = \case + SCFTimedMessages -> timedMessages + SCFFullDelete -> fullDelete + SCFReactions -> reactions + SCFVoice -> voice + SCFCalls -> calls + {-# INLINE getPreference #-} + +setPreference :: forall f. FeatureI f => SChatFeature f -> Maybe FeatureAllowed -> Maybe Preferences -> Preferences +setPreference f allow_ prefs_ = setPreference_ f pref $ fromMaybe emptyChatPrefs prefs_ + where + pref = setAllow <$> allow_ + setAllow :: FeatureAllowed -> FeaturePreference f + setAllow = setField @"allow" (getPreference f prefs) + prefs = mergePreferences Nothing prefs_ + +setPreference' :: SChatFeature f -> Maybe (FeaturePreference f) -> Maybe Preferences -> Preferences +setPreference' f pref_ prefs_ = setPreference_ f pref_ $ fromMaybe emptyChatPrefs prefs_ + +setPreference_ :: SChatFeature f -> Maybe (FeaturePreference f) -> Preferences -> Preferences +setPreference_ f pref_ prefs = + case f of + SCFTimedMessages -> prefs {timedMessages = pref_} + SCFFullDelete -> prefs {fullDelete = pref_} + SCFReactions -> prefs {reactions = pref_} + SCFVoice -> prefs {voice = pref_} + SCFCalls -> prefs {calls = pref_} + +-- collection of optional chat preferences for the user and the contact +data Preferences = Preferences + { timedMessages :: Maybe TimedMessagesPreference, + fullDelete :: Maybe FullDeletePreference, + reactions :: Maybe ReactionsPreference, + voice :: Maybe VoicePreference, + calls :: Maybe CallsPreference + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON Preferences where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +instance ToField Preferences where + toField = toField . encodeJSON + +instance FromField Preferences where + fromField = fromTextField_ decodeJSON + +data GroupFeature + = GFTimedMessages + | GFDirectMessages + | GFFullDelete + | GFReactions + | GFVoice + | GFFiles + deriving (Show, Generic) + +data SGroupFeature (f :: GroupFeature) where + SGFTimedMessages :: SGroupFeature 'GFTimedMessages + SGFDirectMessages :: SGroupFeature 'GFDirectMessages + SGFFullDelete :: SGroupFeature 'GFFullDelete + SGFReactions :: SGroupFeature 'GFReactions + SGFVoice :: SGroupFeature 'GFVoice + SGFFiles :: SGroupFeature 'GFFiles + +deriving instance Show (SGroupFeature f) + +data AGroupFeature = forall f. GroupFeatureI f => AGF (SGroupFeature f) + +deriving instance Show AGroupFeature + +groupFeatureNameText :: GroupFeature -> Text +groupFeatureNameText = \case + GFTimedMessages -> "Disappearing messages" + GFDirectMessages -> "Direct messages" + GFFullDelete -> "Full deletion" + GFReactions -> "Message reactions" + GFVoice -> "Voice messages" + GFFiles -> "Files and media" + +groupFeatureNameText' :: SGroupFeature f -> Text +groupFeatureNameText' = groupFeatureNameText . toGroupFeature + +groupFeatureAllowed' :: GroupFeatureI f => SGroupFeature f -> FullGroupPreferences -> Bool +groupFeatureAllowed' feature prefs = + getField @"enable" (getGroupPreference feature prefs) == FEOn + +instance ToJSON GroupFeature where + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "GF" + toJSON = J.genericToJSON . enumJSON $ dropPrefix "GF" + +instance FromJSON GroupFeature where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "GF" + +allGroupFeatures :: [AGroupFeature] +allGroupFeatures = + [ AGF SGFTimedMessages, + AGF SGFDirectMessages, + AGF SGFFullDelete, + AGF SGFReactions, + AGF SGFVoice, + AGF SGFFiles + ] + +groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) +groupPrefSel = \case + SGFTimedMessages -> timedMessages + SGFDirectMessages -> directMessages + SGFFullDelete -> fullDelete + SGFReactions -> reactions + SGFVoice -> voice + SGFFiles -> files + +toGroupFeature :: SGroupFeature f -> GroupFeature +toGroupFeature = \case + SGFTimedMessages -> GFTimedMessages + SGFDirectMessages -> GFDirectMessages + SGFFullDelete -> GFFullDelete + SGFReactions -> GFReactions + SGFVoice -> GFVoice + SGFFiles -> GFFiles + +class GroupPreferenceI p where + getGroupPreference :: SGroupFeature f -> p -> GroupFeaturePreference f + +instance GroupPreferenceI GroupPreferences where + getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt prefs) + +instance GroupPreferenceI (Maybe GroupPreferences) where + getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) + +instance GroupPreferenceI FullGroupPreferences where + getGroupPreference = \case + SGFTimedMessages -> timedMessages + SGFDirectMessages -> directMessages + SGFFullDelete -> fullDelete + SGFReactions -> reactions + SGFVoice -> voice + SGFFiles -> files + {-# INLINE getGroupPreference #-} + +-- collection of optional group preferences +data GroupPreferences = GroupPreferences + { timedMessages :: Maybe TimedMessagesGroupPreference, + directMessages :: Maybe DirectMessagesGroupPreference, + fullDelete :: Maybe FullDeleteGroupPreference, + reactions :: Maybe ReactionsGroupPreference, + voice :: Maybe VoiceGroupPreference, + files :: Maybe FilesGroupPreference + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupPreferences where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +instance ToField GroupPreferences where + toField = toField . encodeJSON + +instance FromField GroupPreferences where + fromField = fromTextField_ decodeJSON + +setGroupPreference :: forall f. GroupFeatureI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupPreferences -> GroupPreferences +setGroupPreference f enable prefs_ = setGroupPreference_ f pref prefs + where + prefs = mergeGroupPreferences prefs_ + pref :: GroupFeaturePreference f + pref = setField @"enable" (getGroupPreference f prefs) enable + +setGroupPreference' :: SGroupFeature f -> GroupFeaturePreference f -> Maybe GroupPreferences -> GroupPreferences +setGroupPreference' f pref prefs_ = setGroupPreference_ f pref prefs + where + prefs = mergeGroupPreferences prefs_ + +setGroupPreference_ :: SGroupFeature f -> GroupFeaturePreference f -> FullGroupPreferences -> GroupPreferences +setGroupPreference_ f pref prefs = + toGroupPreferences $ case f of + SGFTimedMessages -> prefs {timedMessages = pref} + SGFDirectMessages -> prefs {directMessages = pref} + SGFFullDelete -> prefs {fullDelete = pref} + SGFReactions -> prefs {reactions = pref} + SGFVoice -> prefs {voice = pref} + SGFFiles -> prefs {files = pref} + +setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences +setGroupTimedMessagesPreference pref prefs_ = + toGroupPreferences $ prefs {timedMessages = pref} + where + prefs = mergeGroupPreferences prefs_ + +-- full collection of chat preferences defined in the app - it is used to ensure we include all preferences and to simplify processing +-- if some of the preferences are not defined in Preferences, defaults from defaultChatPrefs are used here. +data FullPreferences = FullPreferences + { timedMessages :: TimedMessagesPreference, + fullDelete :: FullDeletePreference, + reactions :: ReactionsPreference, + voice :: VoicePreference, + calls :: CallsPreference + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON FullPreferences where toEncoding = J.genericToEncoding J.defaultOptions + +-- full collection of group preferences defined in the app - it is used to ensure we include all preferences and to simplify processing +-- if some of the preferences are not defined in GroupPreferences, defaults from defaultGroupPrefs are used here. +data FullGroupPreferences = FullGroupPreferences + { timedMessages :: TimedMessagesGroupPreference, + directMessages :: DirectMessagesGroupPreference, + fullDelete :: FullDeleteGroupPreference, + reactions :: ReactionsGroupPreference, + voice :: VoiceGroupPreference, + files :: FilesGroupPreference + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON FullGroupPreferences where toEncoding = J.genericToEncoding J.defaultOptions + +-- merged preferences of user for a given contact - they differentiate between specific preferences for the contact and global user preferences +data ContactUserPreferences = ContactUserPreferences + { timedMessages :: ContactUserPreference TimedMessagesPreference, + fullDelete :: ContactUserPreference FullDeletePreference, + reactions :: ContactUserPreference ReactionsPreference, + voice :: ContactUserPreference VoicePreference, + calls :: ContactUserPreference CallsPreference + } + deriving (Eq, Show, Generic) + +data ContactUserPreference p = ContactUserPreference + { enabled :: PrefEnabled, + userPreference :: ContactUserPref p, + contactPreference :: p + } + deriving (Eq, Show, Generic) + +data ContactUserPref p = CUPContact {preference :: p} | CUPUser {preference :: p} + deriving (Eq, Show, Generic) + +instance ToJSON ContactUserPreferences where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON p => ToJSON (ContactUserPreference p) where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON p => ToJSON (ContactUserPref p) where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CUP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" + +toChatPrefs :: FullPreferences -> Preferences +toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, calls} = + Preferences + { timedMessages = Just timedMessages, + fullDelete = Just fullDelete, + reactions = Just reactions, + voice = Just voice, + calls = Just calls + } + +defaultChatPrefs :: FullPreferences +defaultChatPrefs = + FullPreferences + { timedMessages = TimedMessagesPreference {allow = FAYes, ttl = Nothing}, + fullDelete = FullDeletePreference {allow = FANo}, + reactions = ReactionsPreference {allow = FAYes}, + voice = VoicePreference {allow = FAYes}, + calls = CallsPreference {allow = FAYes} + } + +emptyChatPrefs :: Preferences +emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing + +defaultGroupPrefs :: FullGroupPreferences +defaultGroupPrefs = + FullGroupPreferences + { timedMessages = TimedMessagesGroupPreference {enable = FEOff, ttl = Just 86400}, + directMessages = DirectMessagesGroupPreference {enable = FEOff}, + fullDelete = FullDeleteGroupPreference {enable = FEOff}, + reactions = ReactionsGroupPreference {enable = FEOn}, + voice = VoiceGroupPreference {enable = FEOn}, + files = FilesGroupPreference {enable = FEOn} + } + +emptyGroupPrefs :: GroupPreferences +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing + +data TimedMessagesPreference = TimedMessagesPreference + { allow :: FeatureAllowed, + ttl :: Maybe Int + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON TimedMessagesPreference where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data FullDeletePreference = FullDeletePreference {allow :: FeatureAllowed} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON FullDeletePreference where toEncoding = J.genericToEncoding J.defaultOptions + +data ReactionsPreference = ReactionsPreference {allow :: FeatureAllowed} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON ReactionsPreference where toEncoding = J.genericToEncoding J.defaultOptions + +data VoicePreference = VoicePreference {allow :: FeatureAllowed} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON VoicePreference where toEncoding = J.genericToEncoding J.defaultOptions + +data CallsPreference = CallsPreference {allow :: FeatureAllowed} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON CallsPreference where toEncoding = J.genericToEncoding J.defaultOptions + +class (Eq (FeaturePreference f), HasField "allow" (FeaturePreference f) FeatureAllowed) => FeatureI f where + type FeaturePreference (f :: ChatFeature) = p | p -> f + sFeature :: SChatFeature f + prefParam :: FeaturePreference f -> Maybe Int + +instance HasField "allow" TimedMessagesPreference FeatureAllowed where + hasField p = (\allow -> p {allow}, allow (p :: TimedMessagesPreference)) + +instance HasField "allow" FullDeletePreference FeatureAllowed where + hasField p = (\allow -> p {allow}, allow (p :: FullDeletePreference)) + +instance HasField "allow" ReactionsPreference FeatureAllowed where + hasField p = (\allow -> p {allow}, allow (p :: ReactionsPreference)) + +instance HasField "allow" VoicePreference FeatureAllowed where + hasField p = (\allow -> p {allow}, allow (p :: VoicePreference)) + +instance HasField "allow" CallsPreference FeatureAllowed where + hasField p = (\allow -> p {allow}, allow (p :: CallsPreference)) + +instance FeatureI 'CFTimedMessages where + type FeaturePreference 'CFTimedMessages = TimedMessagesPreference + sFeature = SCFTimedMessages + prefParam TimedMessagesPreference {ttl} = ttl + +instance FeatureI 'CFFullDelete where + type FeaturePreference 'CFFullDelete = FullDeletePreference + sFeature = SCFFullDelete + prefParam _ = Nothing + +instance FeatureI 'CFReactions where + type FeaturePreference 'CFReactions = ReactionsPreference + sFeature = SCFReactions + prefParam _ = Nothing + +instance FeatureI 'CFVoice where + type FeaturePreference 'CFVoice = VoicePreference + sFeature = SCFVoice + prefParam _ = Nothing + +instance FeatureI 'CFCalls where + type FeaturePreference 'CFCalls = CallsPreference + sFeature = SCFCalls + prefParam _ = Nothing + +data GroupPreference = GroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) + +data TimedMessagesGroupPreference = TimedMessagesGroupPreference + { enable :: GroupFeatureEnabled, + ttl :: Maybe Int + } + deriving (Eq, Show, Generic, FromJSON) + +data DirectMessagesGroupPreference = DirectMessagesGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) + +data FullDeleteGroupPreference = FullDeleteGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) + +data ReactionsGroupPreference = ReactionsGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) + +data VoiceGroupPreference = VoiceGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) + +data FilesGroupPreference = FilesGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON TimedMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON DirectMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON ReactionsGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON FullDeleteGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON VoiceGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +instance ToJSON FilesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + +class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference f) GroupFeatureEnabled) => GroupFeatureI f where + type GroupFeaturePreference (f :: GroupFeature) = p | p -> f + sGroupFeature :: SGroupFeature f + groupPrefParam :: GroupFeaturePreference f -> Maybe Int + +instance HasField "enable" GroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: GroupPreference)) + +instance HasField "enable" TimedMessagesGroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: TimedMessagesGroupPreference)) + +instance HasField "enable" DirectMessagesGroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: DirectMessagesGroupPreference)) + +instance HasField "enable" ReactionsGroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: ReactionsGroupPreference)) + +instance HasField "enable" FullDeleteGroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: FullDeleteGroupPreference)) + +instance HasField "enable" VoiceGroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: VoiceGroupPreference)) + +instance HasField "enable" FilesGroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: FilesGroupPreference)) + +instance GroupFeatureI 'GFTimedMessages where + type GroupFeaturePreference 'GFTimedMessages = TimedMessagesGroupPreference + sGroupFeature = SGFTimedMessages + groupPrefParam TimedMessagesGroupPreference {ttl} = ttl + +instance GroupFeatureI 'GFDirectMessages where + type GroupFeaturePreference 'GFDirectMessages = DirectMessagesGroupPreference + sGroupFeature = SGFDirectMessages + groupPrefParam _ = Nothing + +instance GroupFeatureI 'GFFullDelete where + type GroupFeaturePreference 'GFFullDelete = FullDeleteGroupPreference + sGroupFeature = SGFFullDelete + groupPrefParam _ = Nothing + +instance GroupFeatureI 'GFReactions where + type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference + sGroupFeature = SGFReactions + groupPrefParam _ = Nothing + +instance GroupFeatureI 'GFVoice where + type GroupFeaturePreference 'GFVoice = VoiceGroupPreference + sGroupFeature = SGFVoice + groupPrefParam _ = Nothing + +instance GroupFeatureI 'GFFiles where + type GroupFeaturePreference 'GFFiles = FilesGroupPreference + sGroupFeature = SGFFiles + groupPrefParam _ = Nothing + +groupPrefStateText :: HasField "enable" p GroupFeatureEnabled => GroupFeature -> p -> Maybe Int -> Text +groupPrefStateText feature pref param = + let enabled = getField @"enable" pref + paramText = if enabled == FEOn then groupParamText_ feature param else "" + in groupFeatureNameText feature <> ": " <> safeDecodeUtf8 (strEncode enabled) <> paramText + +groupParamText_ :: GroupFeature -> Maybe Int -> Text +groupParamText_ feature param = case feature of + GFTimedMessages -> maybe "" (\p -> " (" <> timedTTLText p <> ")") param + _ -> "" + +groupPreferenceText :: forall f. GroupFeatureI f => GroupFeaturePreference f -> Text +groupPreferenceText pref = + let feature = toGroupFeature $ sGroupFeature @f + in groupPrefStateText feature pref $ groupPrefParam pref + +timedTTLText :: Int -> Text +timedTTLText 0 = "0 sec" +timedTTLText ttl = do + let (m', s) = ttl `quotRem` 60 + (h', m) = m' `quotRem` 60 + (d', h) = h' `quotRem` 24 + (mm, d) = d' `quotRem` 30 + T.pack . unwords $ + [mms mm | mm /= 0] <> [ds d | d /= 0] <> [hs h | h /= 0] <> [ms m | m /= 0] <> [ss s | s /= 0] + where + ss s = show s <> " sec" + ms m = show m <> " min" + hs 1 = "1 hour" + hs h = show h <> " hours" + ds 1 = "1 day" + ds 7 = "1 week" + ds 14 = "2 weeks" + ds d = show d <> " days" + mms 1 = "1 month" + mms mm = show mm <> " months" + +toGroupPreference :: GroupFeatureI f => GroupFeaturePreference f -> GroupPreference +toGroupPreference p = GroupPreference {enable = getField @"enable" p} + +data FeatureAllowed + = FAAlways -- allow unconditionally + | FAYes -- allow, if peer allows it + | FANo -- do not allow + deriving (Eq, Show, Generic) + +instance FromField FeatureAllowed where fromField = fromBlobField_ strDecode + +instance ToField FeatureAllowed where toField = toField . strEncode + +instance StrEncoding FeatureAllowed where + strEncode = \case + FAAlways -> "always" + FAYes -> "yes" + FANo -> "no" + strDecode = \case + "always" -> Right FAAlways + "yes" -> Right FAYes + "no" -> Right FANo + r -> Left $ "bad FeatureAllowed " <> B.unpack r + strP = strDecode <$?> A.takeByteString + +instance FromJSON FeatureAllowed where + parseJSON = strParseJSON "FeatureAllowed" + +instance ToJSON FeatureAllowed where + toJSON = strToJSON + toEncoding = strToJEncoding + +data GroupFeatureEnabled = FEOn | FEOff + deriving (Eq, Show, Generic) + +instance FromField GroupFeatureEnabled where fromField = fromBlobField_ strDecode + +instance ToField GroupFeatureEnabled where toField = toField . strEncode + +instance StrEncoding GroupFeatureEnabled where + strEncode = \case + FEOn -> "on" + FEOff -> "off" + strDecode = \case + "on" -> Right FEOn + "off" -> Right FEOff + r -> Left $ "bad GroupFeatureEnabled " <> B.unpack r + strP = strDecode <$?> A.takeByteString + +instance FromJSON GroupFeatureEnabled where + parseJSON = strParseJSON "GroupFeatureEnabled" + +instance ToJSON GroupFeatureEnabled where + toJSON = strToJSON + toEncoding = strToJEncoding + +groupFeatureState :: GroupFeatureI f => GroupFeaturePreference f -> (GroupFeatureEnabled, Maybe Int) +groupFeatureState p = + let enable = getField @"enable" p + param = if enable == FEOn then groupPrefParam p else Nothing + in (enable, param) + +mergePreferences :: Maybe Preferences -> Maybe Preferences -> FullPreferences +mergePreferences contactPrefs userPreferences = + FullPreferences + { timedMessages = pref SCFTimedMessages, + fullDelete = pref SCFFullDelete, + reactions = pref SCFReactions, + voice = pref SCFVoice, + calls = pref SCFCalls + } + where + pref :: SChatFeature f -> FeaturePreference f + pref f = + let sel = chatPrefSel f + in fromMaybe (getPreference f defaultChatPrefs) $ (contactPrefs >>= sel) <|> (userPreferences >>= sel) + +mergeGroupPreferences :: Maybe GroupPreferences -> FullGroupPreferences +mergeGroupPreferences groupPreferences = + FullGroupPreferences + { timedMessages = pref SGFTimedMessages, + directMessages = pref SGFDirectMessages, + fullDelete = pref SGFFullDelete, + reactions = pref SGFReactions, + voice = pref SGFVoice, + files = pref SGFFiles + } + where + pref :: SGroupFeature f -> GroupFeaturePreference f + pref pt = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPreferences >>= groupPrefSel pt) + +toGroupPreferences :: FullGroupPreferences -> GroupPreferences +toGroupPreferences groupPreferences = + GroupPreferences + { timedMessages = pref SGFTimedMessages, + directMessages = pref SGFDirectMessages, + fullDelete = pref SGFFullDelete, + reactions = pref SGFReactions, + voice = pref SGFVoice, + files = pref SGFFiles + } + where + pref :: SGroupFeature f -> Maybe (GroupFeaturePreference f) + pref f = Just $ getGroupPreference f groupPreferences + +data PrefEnabled = PrefEnabled {forUser :: Bool, forContact :: Bool} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON PrefEnabled where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +prefEnabled :: FeatureI f => Bool -> FeaturePreference f -> FeaturePreference f -> PrefEnabled +prefEnabled asymmetric user contact = case (getField @"allow" user, getField @"allow" contact) of + (FAAlways, FANo) -> PrefEnabled {forUser = False, forContact = asymmetric} + (FANo, FAAlways) -> PrefEnabled {forUser = asymmetric, forContact = False} + (_, FANo) -> PrefEnabled False False + (FANo, _) -> PrefEnabled False False + _ -> PrefEnabled True True + +prefStateText :: ChatFeature -> FeatureAllowed -> Maybe Int -> Text +prefStateText feature allowed param = case allowed of + FANo -> "cancelled " <> chatFeatureNameText feature + _ -> "offered " <> chatFeatureNameText feature <> paramText_ feature param + +featureStateText :: ChatFeature -> PrefEnabled -> Maybe Int -> Text +featureStateText feature enabled param = + chatFeatureNameText feature <> ": " <> prefEnabledToText feature enabled param <> case enabled of + PrefEnabled {forUser = True} -> paramText_ feature param + _ -> "" + +paramText_ :: ChatFeature -> Maybe Int -> Text +paramText_ feature param = case feature of + CFTimedMessages -> maybe "" (\p -> " (" <> timedTTLText p <> ")") param + _ -> "" + +prefEnabledToText :: ChatFeature -> PrefEnabled -> Maybe Int -> Text +prefEnabledToText f enabled param = case enabled of + PrefEnabled True True -> enabledStr + PrefEnabled False False -> "off" + PrefEnabled {forUser = True, forContact = False} -> enabledStr <> " for you" + PrefEnabled {forUser = False, forContact = True} -> enabledStr <> " for contact" + where + enabledStr = case f of + CFTimedMessages -> if isJust param then "enabled" else "allowed" + _ -> "enabled" + +preferenceText :: forall f. FeatureI f => FeaturePreference f -> Text +preferenceText p = + let feature = chatFeature $ sFeature @f + allowed = getField @"allow" p + paramText = if allowed == FAAlways || allowed == FAYes then paramText_ feature (prefParam p) else "" + in safeDecodeUtf8 (strEncode allowed) <> paramText + +featureState :: FeatureI f => ContactUserPreference (FeaturePreference f) -> (PrefEnabled, Maybe Int) +featureState ContactUserPreference {enabled, userPreference} = + let param = if forUser enabled then prefParam $ preference userPreference else Nothing + in (enabled, param) + +preferenceState :: FeatureI f => FeaturePreference f -> (FeatureAllowed, Maybe Int) +preferenceState pref = + let allow = getField @"allow" pref + param = if allow == FAAlways || allow == FAYes then prefParam pref else Nothing + in (allow, param) + +getContactUserPreference :: SChatFeature f -> ContactUserPreferences -> ContactUserPreference (FeaturePreference f) +getContactUserPreference = \case + SCFTimedMessages -> timedMessages + SCFFullDelete -> fullDelete + SCFReactions -> reactions + SCFVoice -> voice + SCFCalls -> calls diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs new file mode 100644 index 0000000000..fffdd24b9e --- /dev/null +++ b/src/Simplex/Chat/Types/Util.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE LambdaCase #-} + +module Simplex.Chat.Types.Util where + +import Data.Aeson (ToJSON, FromJSON) +import qualified Data.Aeson as J +import Data.ByteString (ByteString) +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import Data.Typeable +import Database.SQLite.Simple (ResultError (..), SQLData (..)) +import Database.SQLite.Simple.FromField (FieldParser, returnError) +import Database.SQLite.Simple.Internal (Field (..)) +import Database.SQLite.Simple.Ok (Ok (Ok)) +import Simplex.Messaging.Util (safeDecodeUtf8) + +encodeJSON :: ToJSON a => a -> Text +encodeJSON = safeDecodeUtf8 . LB.toStrict . J.encode + +decodeJSON :: FromJSON a => Text -> Maybe a +decodeJSON = J.decode . LB.fromStrict . encodeUtf8 + +fromBlobField_ :: Typeable k => (ByteString -> Either String k) -> FieldParser k +fromBlobField_ p = \case + f@(Field (SQLBlob b) _) -> + case p b of + Right k -> Ok k + Left e -> returnError ConversionFailed f ("could not parse field: " ++ e) + f -> returnError ConversionFailed f "expecting SQLBlob column type" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6002665766..03b7956274 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -42,6 +42,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import qualified Simplex.FileTransfer.Protocol as XFTP import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..)) @@ -464,12 +465,21 @@ localTs tz ts = do viewChatItemStatusUpdated :: AChatItem -> CurrentTime -> TimeZone -> Bool -> Bool -> [StyledString] viewChatItemStatusUpdated (AChatItem _ _ chat item@ChatItem {meta = CIMeta {itemStatus}}) ts tz testView showReceipts = case itemStatus of - CISSndRcvd rcptStatus -> + CISSndRcvd rcptStatus SSPPartial -> + if testView && showReceipts + then prependFirst (viewDeliveryReceiptPartial rcptStatus <> " ") $ viewChatItem chat item False ts tz + else [] + CISSndRcvd rcptStatus SSPComplete -> if testView && showReceipts then prependFirst (viewDeliveryReceipt rcptStatus <> " ") $ viewChatItem chat item False ts tz else [] _ -> [] +viewDeliveryReceiptPartial :: MsgReceiptStatus -> StyledString +viewDeliveryReceiptPartial = \case + MROk -> "%" + MRBadMsgHash -> ttyError' "%!" + viewDeliveryReceipt :: MsgReceiptStatus -> StyledString viewDeliveryReceipt = \case MROk -> "⩗" diff --git a/stack.yaml b/stack.yaml index c5607a6a3f..e96175d919 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 7e2b30945009e0cbe02a6b11a585595d3e4858b0 + commit: fdbfe0e8d159f394f6eb0f5168620da8694033cd - github: kazu-yamamoto/http2 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 # - ../direct-sqlcipher diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7d4774500f..7b93b97589 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -2213,13 +2213,13 @@ testConfigureDeliveryReceipts tmp = noReceipt cath alice "4" -- configure receipts for user contacts - alice ##> "/_set receipts 1 on" + alice ##> "/_set receipts contacts 1 on" alice <## "ok" receipt bob alice "5" receipt cath alice "6" -- configure receipts for user contacts (terminal api) - alice ##> "/set receipts off" + alice ##> "/set receipts contacts off" alice <## "ok" noReceipt bob alice "7" noReceipt cath alice "8" @@ -2231,18 +2231,18 @@ testConfigureDeliveryReceipts tmp = noReceipt cath alice "10" -- configure receipts for user contacts (don't clear overrides) - alice ##> "/_set receipts 1 off" + alice ##> "/_set receipts contacts 1 off" alice <## "ok" receipt bob alice "11" noReceipt cath alice "12" - alice ##> "/_set receipts 1 off clear_overrides=off" + alice ##> "/_set receipts contacts 1 off clear_overrides=off" alice <## "ok" receipt bob alice "13" noReceipt cath alice "14" -- configure receipts for user contacts (clear overrides) - alice ##> "/set receipts off clear_overrides=on" + alice ##> "/set receipts contacts off clear_overrides=on" alice <## "ok" noReceipt bob alice "15" noReceipt cath alice "16" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index bb556062cf..6e1e761208 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -59,8 +59,11 @@ chatGroupTests = do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet it "synchronize ratchets, reset connection code" testGroupSyncRatchetCodeReset - describe "message reactions" $ do + describe "group message reactions" $ do it "set group message reactions" testSetGroupMessageReactions + describe "group delivery receipts" $ do + it "should send delivery receipts in group" testSendGroupDeliveryReceipts + it "should send delivery receipts in group depending on configuration" testConfigureGroupDeliveryReceipts testGroup :: HasCallStack => SpecWith FilePath testGroup = versionTestMatrix3 runTestGroup @@ -198,6 +201,7 @@ testGroupShared alice bob cath checkMessages = do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] bob @@@ [("@alice", "received invitation to join group team as admin"), ("@cath", "hey"), ("#team", "received")] -- test clearing chat + threadDelay 1000000 alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") alice #$> ("/_get chat #1 count=100", chat, []) bob #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") @@ -976,6 +980,7 @@ testGroupMessageDelete = (bob <# "#team alice> hello!") (cath <# "#team alice> hello!") + threadDelay 1000000 msgItemId1 <- lastItemId alice alice #$> ("/_delete item #1 " <> msgItemId1 <> " internal", id, "message deleted") @@ -2197,47 +2202,46 @@ testGroupLinkLeaveDelete = testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - createGroup3 "team" alice bob cath - alice #> "#team hi" - [bob, cath] *<# "#team alice> hi" - bob #> "#team hey" - [alice, cath] *<# "#team bob> hey" - setupDesynchronizedRatchet tmp alice cath - withTestChat tmp "bob" $ \bob -> do - bob <## "2 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" - alice #> "#team hello again" - bob <# "#team alice> skipped message ID 8..10" - [bob, cath] *<# "#team alice> hello again" - bob #> "#team received!" - alice <# "#team bob> received!" - cath <# "#team bob> received!" + withNewTestChat tmp "bob" bobProfile $ \bob -> do + createGroup2 "team" alice bob + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + setupDesynchronizedRatchet tmp alice + withTestChat tmp "bob" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "#team: connected to server(s)" + alice #> "#team hello again" + bob <# "#team alice> skipped message ID 10..12" + bob <# "#team alice> hello again" + bob #> "#team received!" + alice <# "#team bob> received!" -setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> TestCC -> IO () -setupDesynchronizedRatchet tmp alice cath = do +setupDesynchronizedRatchet :: HasCallStack => FilePath -> TestCC -> IO () +setupDesynchronizedRatchet tmp alice = do copyDb "bob" "bob_old" withTestChat tmp "bob" $ \bob -> do - bob <## "2 contacts connected (use /cs for the list)" + bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" - alice #> "#team hello" - [bob, cath] *<# "#team alice> hello" - bob #> "#team hello too" - [alice, cath] *<# "#team bob> hello too" + alice #> "#team 1" + bob <# "#team alice> 1" + bob #> "#team 2" + alice <# "#team bob> 2" + alice #> "#team 3" + bob <# "#team alice> 3" + bob #> "#team 4" + alice <# "#team bob> 4" withTestChat tmp "bob_old" $ \bob -> do - bob <## "2 contacts connected (use /cs for the list)" + bob <## "1 contacts connected (use /cs for the list)" bob <## "#team: connected to server(s)" bob ##> "/sync #team alice" bob <## "error: command is prohibited" alice #> "#team 1" bob <## "#team alice: decryption error (connection out of sync), synchronization required" bob <## "use /sync #team alice to synchronize" - cath <# "#team alice> 1" alice #> "#team 2" - cath <# "#team alice> 2" alice #> "#team 3" - cath <# "#team alice> 3" (bob "/tail #team 1" bob <# "#team alice> decryption error, possibly due to the device change (header, 3 messages)" @@ -2249,99 +2253,82 @@ setupDesynchronizedRatchet tmp alice cath = do testGroupSyncRatchet :: HasCallStack => FilePath -> IO () testGroupSyncRatchet tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - createGroup3 "team" alice bob cath - alice #> "#team hi" - [bob, cath] *<# "#team alice> hi" - bob #> "#team hey" - [alice, cath] *<# "#team bob> hey" - setupDesynchronizedRatchet tmp alice cath - withTestChat tmp "bob_old" $ \bob -> do - bob <## "2 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" - -- cath and bob are not fully de-synchronized - bob `send` "#team 1" - bob <## "error: command is prohibited" -- silence? - bob <# "#team 1" - (alice "#team 1" - [alice, bob] *<# "#team cath> 1" - bob `send` "#team 2" - bob <## "error: command is prohibited" - bob <# "#team 2" - cath <# "#team bob> incorrect message hash" - cath <# "#team bob> 2" - bob `send` "#team 3" - bob <## "error: command is prohibited" - bob <# "#team 3" - cath <# "#team bob> 3" - -- synchronize bob and alice - bob ##> "/sync #team alice" - bob <## "connection synchronization started" - alice <## "#team bob: connection synchronization agreed" - bob <## "#team alice: connection synchronization agreed" - alice <## "#team bob: connection synchronized" - bob <## "#team alice: connection synchronized" + withNewTestChat tmp "bob" bobProfile $ \bob -> do + createGroup2 "team" alice bob + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + setupDesynchronizedRatchet tmp alice + withTestChat tmp "bob_old" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "#team: connected to server(s)" + bob `send` "#team 1" + bob <## "error: command is prohibited" -- silence? + bob <# "#team 1" + (alice "/sync #team alice" + bob <## "connection synchronization started" + alice <## "#team bob: connection synchronization agreed" + bob <## "#team alice: connection synchronization agreed" + alice <## "#team bob: connection synchronized" + bob <## "#team alice: connection synchronized" - bob #$> ("/_get chat #1 count=3", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "connection synchronized")]) - alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) + bob #$> ("/_get chat #1 count=3", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "connection synchronized")]) + alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) - alice #> "#team hello again" - [bob, cath] *<# "#team alice> hello again" - bob #> "#team received!" - alice <# "#team bob> received!" - cath <# "#team bob> received!" + alice #> "#team hello again" + bob <# "#team alice> hello again" + bob #> "#team received!" + alice <# "#team bob> received!" testGroupSyncRatchetCodeReset :: HasCallStack => FilePath -> IO () testGroupSyncRatchetCodeReset tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - createGroup3 "team" alice bob cath - alice #> "#team hi" - [bob, cath] *<# "#team alice> hi" - bob #> "#team hey" - [alice, cath] *<# "#team bob> hey" - -- connection not verified - bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" - -- verify connection - alice ##> "/code #team bob" - bCode <- getTermLine alice - bob ##> ("/verify #team alice " <> bCode) - bob <## "connection verified" - -- connection verified - bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection verified" - setupDesynchronizedRatchet tmp alice cath - withTestChat tmp "bob_old" $ \bob -> do - bob <## "2 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" - bob ##> "/sync #team alice" - bob <## "connection synchronization started" - alice <## "#team bob: connection synchronization agreed" - bob <## "#team alice: connection synchronization agreed" - bob <## "#team alice: security code changed" - alice <## "#team bob: connection synchronized" - bob <## "#team alice: connection synchronized" + withNewTestChat tmp "bob" bobProfile $ \bob -> do + createGroup2 "team" alice bob + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + -- connection not verified + bob ##> "/i #team alice" + aliceInfo bob + bob <## "connection not verified, use /code command to see security code" + -- verify connection + alice ##> "/code #team bob" + bCode <- getTermLine alice + bob ##> ("/verify #team alice " <> bCode) + bob <## "connection verified" + -- connection verified + bob ##> "/i #team alice" + aliceInfo bob + bob <## "connection verified" + setupDesynchronizedRatchet tmp alice + withTestChat tmp "bob_old" $ \bob -> do + bob <## "1 contacts connected (use /cs for the list)" + bob <## "#team: connected to server(s)" + bob ##> "/sync #team alice" + bob <## "connection synchronization started" + alice <## "#team bob: connection synchronization agreed" + bob <## "#team alice: connection synchronization agreed" + bob <## "#team alice: security code changed" + alice <## "#team bob: connection synchronized" + bob <## "#team alice: connection synchronized" - bob #$> ("/_get chat #1 count=4", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")]) - alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) + bob #$> ("/_get chat #1 count=4", chat, [(1, "connection synchronization started for alice"), (0, "connection synchronization agreed"), (0, "security code changed"), (0, "connection synchronized")]) + alice #$> ("/_get chat #1 count=2", chat, [(0, "connection synchronization agreed"), (0, "connection synchronized")]) - -- connection not verified - bob ##> "/i #team alice" - aliceInfo bob - bob <## "connection not verified, use /code command to see security code" + -- connection not verified + bob ##> "/i #team alice" + aliceInfo bob + bob <## "connection not verified, use /code command to see security code" - alice #> "#team hello again" - [bob, cath] *<# "#team alice> hello again" - bob #> "#team received!" - alice <# "#team bob> received!" - (cath "#team hello again" + bob <# "#team alice> hello again" + bob #> "#team received!" + alice <# "#team bob> received!" where aliceInfo :: HasCallStack => TestCC -> IO () aliceInfo bob = do @@ -2418,3 +2405,182 @@ testSetGroupMessageReactions = cath ##> "/tail #team 1" cath <# "#team alice> hi" cath <## " 👍 1" + +testSendGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () +testSendGroupDeliveryReceipts tmp = + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + -- turn off contacts receipts for tests + alice ##> "/_set receipts contacts 1 off" + alice <## "ok" + bob ##> "/_set receipts contacts 1 off" + bob <## "ok" + cath ##> "/_set receipts contacts 1 off" + cath <## "ok" + + createGroup3 "team" alice bob cath + threadDelay 1000000 + + alice #> "#team hi" + bob <# "#team alice> hi" + cath <# "#team alice> hi" + alice % "#team hi" + alice ⩗ "#team hi" + + bob #> "#team hey" + alice <# "#team bob> hey" + cath <# "#team bob> hey" + bob % "#team hey" + bob ⩗ "#team hey" + where + cfg = testCfg {showReceipts = True} + +testConfigureGroupDeliveryReceipts :: HasCallStack => FilePath -> IO () +testConfigureGroupDeliveryReceipts tmp = + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp cfg "cath" cathProfile $ \cath -> do + -- turn off contacts receipts for tests + alice ##> "/_set receipts contacts 1 off" + alice <## "ok" + bob ##> "/_set receipts contacts 1 off" + bob <## "ok" + cath ##> "/_set receipts contacts 1 off" + cath <## "ok" + + -- create group 1 + createGroup3 "team" alice bob cath + threadDelay 1000000 + + -- create group 2 + alice ##> "/g club" + alice <## "group #club is created" + alice <## "to add members use /a club or /create link #club" + alice ##> "/a club bob" + concurrentlyN_ + [ alice <## "invitation to join the group #club sent to bob", + do + bob <## "#club: alice invites you to join the group as admin" + bob <## "use /j club to accept" + ] + bob ##> "/j club" + concurrently_ + (alice <## "#club: bob joined the group") + (bob <## "#club: you joined the group") + alice ##> "/a club cath" + concurrentlyN_ + [ alice <## "invitation to join the group #club sent to cath", + do + cath <## "#club: alice invites you to join the group as admin" + cath <## "use /j club to accept" + ] + cath ##> "/j club" + concurrentlyN_ + [ alice <## "#club: cath joined the group", + do + cath <## "#club: you joined the group" + cath <## "#club: member bob_1 (Bob) is connected" + cath <## "contact bob_1 is merged into bob" + cath <## "use @bob to send messages", + do + bob <## "#club: alice added cath_1 (Catherine) to the group (connecting...)" + bob <## "#club: new member cath_1 is connected" + bob <## "contact cath_1 is merged into cath" + bob <## "use @cath to send messages" + ] + threadDelay 1000000 + + -- for new users receipts are enabled by default + receipt bob alice cath "team" "1" + receipt bob alice cath "club" "2" + + -- configure receipts in all chats + alice ##> "/set receipts all off" + alice <## "ok" + partialReceipt bob alice cath "team" "3" + partialReceipt bob alice cath "club" "4" + + -- configure receipts for user groups + alice ##> "/_set receipts groups 1 on" + alice <## "ok" + receipt bob alice cath "team" "5" + receipt bob alice cath "club" "6" + + -- configure receipts for user groups (terminal api) + alice ##> "/set receipts groups off" + alice <## "ok" + partialReceipt bob alice cath "team" "7" + partialReceipt bob alice cath "club" "8" + + -- configure receipts for group + alice ##> "/receipts #team on" + alice <## "ok" + receipt bob alice cath "team" "9" + partialReceipt bob alice cath "club" "10" + + -- configure receipts for user groups (don't clear overrides) + alice ##> "/_set receipts groups 1 off" + alice <## "ok" + receipt bob alice cath "team" "11" + partialReceipt bob alice cath "club" "12" + + alice ##> "/_set receipts groups 1 off clear_overrides=off" + alice <## "ok" + receipt bob alice cath "team" "13" + partialReceipt bob alice cath "club" "14" + + -- configure receipts for user groups (clear overrides) + alice ##> "/set receipts groups off clear_overrides=on" + alice <## "ok" + partialReceipt bob alice cath "team" "15" + partialReceipt bob alice cath "club" "16" + + -- configure receipts for group, reset to default + alice ##> "/receipts #team on" + alice <## "ok" + receipt bob alice cath "team" "17" + partialReceipt bob alice cath "club" "18" + + alice ##> "/receipts #team default" + alice <## "ok" + partialReceipt bob alice cath "team" "19" + partialReceipt bob alice cath "club" "20" + + -- cath - disable receipts for user groups + cath ##> "/_set receipts groups 1 off" + cath <## "ok" + noReceipt bob alice cath "team" "21" + noReceipt bob alice cath "club" "22" + + -- partial, all receipts in one group; no receipts in other group + cath ##> "/receipts #team on" + cath <## "ok" + partialReceipt bob alice cath "team" "23" + noReceipt bob alice cath "club" "24" + + alice ##> "/receipts #team on" + alice <## "ok" + receipt bob alice cath "team" "25" + noReceipt bob alice cath "club" "26" + where + cfg = testCfg {showReceipts = True} + receipt cc1 cc2 cc3 gName msg = do + name1 <- userName cc1 + cc1 #> ("#" <> gName <> " " <> msg) + cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc1 % ("#" <> gName <> " " <> msg) + cc1 ⩗ ("#" <> gName <> " " <> msg) + partialReceipt cc1 cc2 cc3 gName msg = do + name1 <- userName cc1 + cc1 #> ("#" <> gName <> " " <> msg) + cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc1 % ("#" <> gName <> " " <> msg) + noReceipt cc1 cc2 cc3 gName msg = do + name1 <- userName cc1 + cc1 #> ("#" <> gName <> " " <> msg) + cc2 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc3 <# ("#" <> gName <> " " <> name1 <> "> " <> msg) + cc1 getTermLine cc) `shouldReturn` "i " <> line (⩗) :: HasCallStack => TestCC -> String -> Expectation cc ⩗ line = (dropTime . dropReceipt <$> getTermLine cc) `shouldReturn` line +(%) :: HasCallStack => TestCC -> String -> Expectation +cc % line = (dropTime . dropPartialReceipt <$> getTermLine cc) `shouldReturn` line + ( TestCC -> Expectation ( Just text _ -> Nothing +dropPartialReceipt :: HasCallStack => String -> String +dropPartialReceipt msg = fromMaybe err $ dropPartialReceipt_ msg + where + err = error $ "invalid partial receipt: " <> msg + +dropPartialReceipt_ :: String -> Maybe String +dropPartialReceipt_ msg = case splitAt 2 msg of + ("% ", text) -> Just text + _ -> Nothing + getInvitation :: HasCallStack => TestCC -> IO String getInvitation cc = do cc <## "pass this invitation link to your contact (via another channel):" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 220c8a0d3e..31c0803547 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -27,16 +27,16 @@ noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"e activeUserExists :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":false},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" +activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":false},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" +activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" #endif activeUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":false}}}}" +activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":false}}}" +activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" #endif chatStarted :: String @@ -75,7 +75,7 @@ pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> #endif userJSON :: String -userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":false}" +userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" parsedMarkdown :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index c0956f2876..6f7e0b8cf4 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -10,6 +10,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Simplex.Chat.Protocol import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet diff --git a/website/src/_includes/blog_previews/20230422.html b/website/src/_includes/blog_previews/20230422.html index afb6cf757e..d95ef0e9ec 100644 --- a/website/src/_includes/blog_previews/20230422.html +++ b/website/src/_includes/blog_previews/20230422.html @@ -1,4 +1,4 @@ -

SimpleX Chat vision and funding:

+

SimpleX Chat vision and funding:

  • why is it a commercial company?
  • @@ -6,7 +6,7 @@
  • what is next?
-

v5.0 is released:

+

v5.0 is released:

  • send videos and files up to 1gb
  • diff --git a/website/src/_includes/blog_previews/20230523.html b/website/src/_includes/blog_previews/20230523.html index ccafa44cc8..7223501f11 100644 --- a/website/src/_includes/blog_previews/20230523.html +++ b/website/src/_includes/blog_previews/20230523.html @@ -1,4 +1,4 @@ -

    v5.1 is released:

    +

    v5.1 is released:

    • message reactions 🚀
    • diff --git a/website/src/_includes/blog_previews/20230722.html b/website/src/_includes/blog_previews/20230722.html new file mode 100644 index 0000000000..de6944817b --- /dev/null +++ b/website/src/_includes/blog_previews/20230722.html @@ -0,0 +1,29 @@ +

      v5.2 is released:

      + +
        +
      • message delivery receipts! ✅
      • +
      • filter favorite and unread chats
      • +
      • better disappearing messages
      • +
      • prohibit message reactions
      • +
      • more usable groups:

        +
          +
        • view full original replied message in info.
        • +
        • share your address via your chat profile
        • +
        • search new and existing members
        • +
        +
      • +
      • stability improvements:

        +
          +
        • keep your connections working after restoring from backup.
        • +
        • restart app (Android) or reconnect servers (iOS)
        • +
        • more stable connections and message delivery
        • +
        +
      • +
      + +

      Platform evolution:

      + +
        +
      • problems of public groups and their future
      • +
      • what about read receipts?
      • +