diff --git a/README.md b/README.md index 601ab5a161..5583fad0b5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SimpleX logo -Invest in SimpleX Chat. [Register now](https://simplexchat.typeform.com/crowdfunding). +Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplexchat). # SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index a87b9b46f4..51746766bd 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -859,8 +859,8 @@ enum ConnectTarget { func strConnectTarget(_ str: String) -> ConnectTarget? { let parsedMd = parseSimpleXMarkdown(str) let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? [] - return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format { - .link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) + return if links.count == 1, case let .simplexLink(showText, linkType, simplexUri, smpHosts) = links[0].format { + .link(text: showText != nil ? simplexUri : links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) } else if links.isEmpty, let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }), case let .simplexName(nameInfo) = nameFt.format { diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index b7753e8539..6ea24ec91c 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -8,6 +8,7 @@ // Spec: spec/client/navigation.md import SwiftUI +import StoreKit import SimpleXChat private struct VersionDescription { @@ -41,6 +42,8 @@ private struct FeatureView { let view: () -> any View } +private let isInUS = SKStorefront().countryCode == "USA" + private let versionDescriptions: [VersionDescription] = [ VersionDescription( version: "v4.2", @@ -667,7 +670,13 @@ private let versionDescriptions: [VersionDescription] = [ VersionDescription( version: "v7.0", post: nil, - features: [ + features: (isInUS ? [ + .view(FeatureView( + icon: nil, + title: "Invest in SimpleX Chat", + view: { InvestInSimpleXChat() } + )) + ] : []) + [ .feature(Description( icon: "at", title: "SimpleX public names (BETA)", @@ -762,6 +771,42 @@ fileprivate struct CreateUpdateAddressShortLink: View { } } +fileprivate struct InvestInSimpleXChat: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + + var body: some View { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "dollarsign.circle") + .symbolRenderingMode(.monochrome) + .foregroundColor(theme.colors.secondary) + .frame(minWidth: 30, alignment: .center) + Text(verbatim: "Invest in SimpleX Chat").font(.title3).bold() + } + Text(verbatim: "Equity crowdfunding launched!") + .multilineTextAlignment(.leading) + .lineLimit(2) + if let url = URL("https://wefunder.com/simplexchat") { + ExternalLink(destination: url) { + HStack { + Text(verbatim: "Learn more on Wefunder") + Image(systemName: "arrow.up.right.circle") + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + Image(colorScheme == .light ? "own-stake" : "own-stake-light") + .resizable() + .scaledToFill() + .frame(width: UIScreen.main.bounds.width / 5) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + private enum WhatsNewViewSheet: Identifiable { case showConditions diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0f7dab1eba..3b0c6ed57d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -563,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -735,8 +735,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -822,8 +822,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a */, ); path = Libraries; sourceTree = ""; @@ -2081,7 +2081,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2106,7 +2106,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2131,7 +2131,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2156,7 +2156,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2173,11 +2173,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2193,11 +2193,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2218,7 +2218,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2233,7 +2233,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2255,7 +2255,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2270,7 +2270,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2292,7 +2292,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2318,7 +2318,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2343,7 +2343,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2370,7 +2370,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2397,7 +2397,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2412,7 +2412,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2431,7 +2431,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2446,7 +2446,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/README.md b/apps/multiplatform/README.md index eef1048ada..54e17d4c4e 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -9,11 +9,19 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX ## Build Commands ```bash -# Android debug APK -./gradlew assembleDebug +# Android debug APK, assembleGoogleDebug builds the flavor with the Play Billing dependency +./gradlew assembleFossDebug -# Android release APK -./gradlew assembleRelease +# Android release APK, distributed via F-Droid and GitHub +./gradlew assembleFossRelease + +# Android app bundle, distributed via Google Play, includes Play Billing +./gradlew bundleGoogleRelease + +# Always name the flavor for releases. The aggregate tasks (build, assemble, assembleRelease, +# bundle, bundleRelease) fail on purpose: they would package a release APK with Play Billing, +# or an app bundle without it. +# The fdroiddata recipe defaults to assembleRelease and must be changed to assembleFossRelease. # Desktop distribution (current OS) ./gradlew :desktop:packageDistributionForCurrentOS @@ -22,7 +30,7 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX ./gradlew desktopTest # Run Android instrumented tests (requires connected device/emulator) -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest # Build native libraries for all platforms ./gradlew common:cmakeBuild -PcrossCompile diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 5255319194..419fef90b9 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -35,6 +35,21 @@ android { manifestPlaceholders["extract_native_libs"] = rootProject.extra["compression.level"] as Int != 0 } + // `google` is distributed via Google Play as an app bundle and includes Play Billing. + // `foss` is distributed via F-Droid and as APKs on GitHub, without Play dependencies. + flavorDimensions += "store" + productFlavors { + create("google") { + dimension = "store" + buildConfigField("boolean", "PLAY_STORE", "true") + } + create("foss") { + dimension = "store" + isDefault = true + buildConfigField("boolean", "PLAY_STORE", "false") + } + } + buildTypes { debug { applicationIdSuffix = rootProject.extra["application_id.suffix"] as String @@ -128,8 +143,28 @@ android { } } +// The graph is checked rather than the requested task, because every aggregate task +// (assemble, assembleRelease, build, bundle, ...) packages these variants too. +val projectPath = project.path +val apkTasks = setOf("packageFossDebug", "packageGoogleDebug", "packageFossRelease", "packageGoogleRelease") +val apkTaskPaths = apkTasks.map { "$projectPath:$it" }.toSet() +val bundleTaskPaths = apkTaskPaths.map { it + "Bundle" }.toSet() +gradle.taskGraph.whenReady { + if (hasTask("$projectPath:packageGoogleRelease")) { + throw GradleException("A release apk must not include Play Billing, use assembleFossRelease or bundleGoogleRelease") + } + if (hasTask("$projectPath:packageFossReleaseBundle")) { + throw GradleException("An app bundle must include Play Billing, use bundleGoogleRelease or assembleFossRelease") + } + // `isBundle` above is derived from the whole invocation, so a bundle in it disables abi splits + if (apkTaskPaths.any { hasTask(it) } && bundleTaskPaths.any { hasTask(it) }) { + throw GradleException("Build the apks and the bundle in separate invocations, the bundle disables abi splits") + } +} + dependencies { implementation(project(":common")) + "googleImplementation"("com.android.billingclient:billing:9.1.0") implementation("androidx.core:core-ktx:1.13.1") //implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}") //implementation("androidx.compose.material:material:$compose_version") @@ -160,58 +195,61 @@ dependencies { tasks { val compressApk by creating { doLast { - val isRelease = gradle.startParameter.taskNames.find { it.lowercase().contains("release") } != null - val buildType: String = if (isRelease) "release" else "debug" val javaHome = System.getProperties()["java.home"] ?: org.gradle.internal.jvm.Jvm.current().javaHome val sdkDir = android.sdkDirectory.absolutePath - val keyAlias: String - val keyPassword: String - val storeFile: String - val storePassword: String - if (project.properties["android.injected.signing.key.alias"] != null) { - keyAlias = project.properties["android.injected.signing.key.alias"] as String - keyPassword = project.properties["android.injected.signing.key.password"] as String - storeFile = project.properties["android.injected.signing.store.file"] as String - storePassword = project.properties["android.injected.signing.store.password"] as String - } else { - try { - val gradleConfig = android.signingConfigs.getByName(buildType) - keyAlias = gradleConfig.keyAlias!! - keyPassword = gradleConfig.keyPassword!! - storeFile = gradleConfig.storeFile!!.absolutePath - storePassword = gradleConfig.storePassword!! - } catch (e: UnknownDomainObjectException) { - // There is no signing config for current build type, can"t sign the apk - println("No signing configs for this build type: $buildType") - return@doLast + // A single invocation can package more than one variant, for example assembleDebug + gradle.taskGraph.allTasks.filter { it.path in apkTaskPaths }.forEach { packageTask -> + val variant = packageTask.name.removePrefix("package") + val buildType: String = if (variant.endsWith("Release")) "release" else "debug" + val keyAlias: String + val keyPassword: String + val storeFile: String + val storePassword: String + if (project.properties["android.injected.signing.key.alias"] != null) { + keyAlias = project.properties["android.injected.signing.key.alias"] as String + keyPassword = project.properties["android.injected.signing.key.password"] as String + storeFile = project.properties["android.injected.signing.store.file"] as String + storePassword = project.properties["android.injected.signing.store.password"] as String + } else { + try { + val gradleConfig = android.signingConfigs.getByName(buildType) + keyAlias = gradleConfig.keyAlias!! + keyPassword = gradleConfig.keyPassword!! + storeFile = gradleConfig.storeFile!!.absolutePath + storePassword = gradleConfig.storePassword!! + } catch (e: UnknownDomainObjectException) { + // There is no signing config for current build type, can"t sign the apk + println("No signing configs for this build type: $buildType") + return@forEach + } + } + val outputDir = packageTask.outputs.files.files.last() + exec { + workingDir("../../scripts/android") + environment = mapOf( + "JAVA_HOME" to "$javaHome", + "PATH" to "${System.getenv("PATH")}:$javaHome/bin" + ) + commandLine = listOf( + "./compress-and-sign-apk.sh", + "${rootProject.extra["compression.level"]}", + "$outputDir", + sdkDir, + storeFile, + storePassword, + keyAlias, + keyPassword + ) } - } - lateinit var outputDir: File - named(if (isRelease) "packageRelease" else "packageDebug") { - outputDir = outputs.files.files.last() - } - exec { - workingDir("../../scripts/android") - environment = mapOf( - "JAVA_HOME" to "$javaHome", - "PATH" to "${System.getenv("PATH")}:$javaHome/bin" - ) - commandLine = listOf( - "./compress-and-sign-apk.sh", - "${rootProject.extra["compression.level"]}", - "$outputDir", - sdkDir, - storeFile, - storePassword, - keyAlias, - keyPassword - ) - } - if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { - File(outputDir, "android-release.apk").renameTo(File(outputDir, "simplex.apk")) - File(outputDir, "android-armeabi-v7a-release.apk").renameTo(File(outputDir, "simplex-armv7a.apk")) - File(outputDir, "android-arm64-v8a-release.apk").renameTo(File(outputDir, "simplex.apk")) + if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { + val flavor = variant.removeSuffix("Release").lowercase() + mapOf("arm64-v8a" to "simplex.apk", "armeabi-v7a" to "simplex-armv7a.apk").forEach { (abi, name) -> + if (!File(outputDir, "android-$flavor-$abi-release.apk").renameTo(File(outputDir, name))) { + logger.warn("No $abi apk to rename to $name") + } + } + } } // View all gradle properties set // project.properties.each { k, v -> println "$k -> $v" } @@ -221,9 +259,7 @@ tasks { // Don"t do anything if no compression is needed if (rootProject.extra["compression.level"] as Int != 0) { whenTaskAdded { - if (name == "packageDebug") { - finalizedBy(compressApk) - } else if (name == "packageRelease") { + if (name in apkTasks) { finalizedBy(compressApk) } } diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt new file mode 100644 index 0000000000..181fe42389 --- /dev/null +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -0,0 +1,4 @@ +package chat.simplex.app + +// Play Billing is only in the google flavor, so the Play country stays unknown here +fun loadPlayStoreCountry() {} diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt new file mode 100644 index 0000000000..a0e7734ff0 --- /dev/null +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -0,0 +1,31 @@ +package chat.simplex.app + +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.androidPlayStoreCountry +import com.android.billingclient.api.* + +// Requests the country of the Google Play account into [androidPlayStoreCountry]. +// It stays null when Play is unavailable or the user is not signed in. +fun loadPlayStoreCountry() { + val client = BillingClient.newBuilder(androidAppContext) + .setListener { _, _ -> } + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + client.endConnection() + return + } + client.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build()) { configResult, config -> + if (configResult.responseCode == BillingClient.BillingResponseCode.OK) { + androidPlayStoreCountry.value = config?.countryCode + } + client.endConnection() + } + } + + // The connection is only used for this one request, it is not retried + override fun onBillingServiceDisconnected() = client.endConnection() + }) +} 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 83767f90d7..ce47d2c5de 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 @@ -341,6 +341,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidIsXiaomiDevice(): Boolean = setOf("xiaomi", "redmi", "poco").contains(Build.BRAND.lowercase()) + override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { @@ -370,6 +372,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidCreateActiveCallState(): Closeable = ActiveCallState() override val androidApiLevel: Int get() = Build.VERSION.SDK_INT + + override val androidIsPlayStoreBuild: Boolean get() = BuildConfig.PLAY_STORE } } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 98845365fc..1f55b9c660 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -189,7 +189,6 @@ buildConfig { buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") - buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets") } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index 83677f3318..1826b2114e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -42,10 +42,9 @@ actual fun SavePassphraseSetting( } Text( stringResource(MR.strings.save_passphrase_in_keychain), - Modifier.padding(end = 24.dp), + Modifier.weight(1f).padding(end = 24.dp), color = Color.Unspecified ) - Spacer(Modifier.fillMaxWidth().weight(1f)) DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, 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 index c98f8f9f89..141d2d2665 100644 --- 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 @@ -341,6 +341,19 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea VideoPlayerInterface.PreviewAndDuration(null, 0, 0) } +actual suspend fun hasVideoTrack(uri: URI): Boolean { + val mmr = MediaMetadataRetriever() + return try { + mmr.setDataSource(androidAppContext, uri.toUri()) + mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) == "yes" + } catch (e: Exception) { + Log.e(TAG, "Utils.android hasVideoTrack error: ${e.message}") + false + } finally { + mmr.release() + } +} + 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/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 7a96bd99d2..140c1951ee 100644 --- 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 @@ -1,5 +1,7 @@ package chat.simplex.common.platform +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme @@ -30,6 +32,9 @@ else val databaseBackend: String = if (appPlatform == AppPlatform.ANDROID) "sqlite" else BuildConfigCommon.DATABASE_BACKEND +// Country of the Google Play account, only set in the google flavor of the Android app +val androidPlayStoreCountry: MutableState = mutableStateOf(null) + class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { if (size > capacity) removeFirstOrNull() 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 index 448100bc17..b46123c9cf 100644 --- 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 @@ -29,7 +29,11 @@ interface PlatformInterface { fun androidRestartNetworkObserver() {} fun androidCreateActiveCallState(): Closeable = Closeable { } fun androidIsXiaomiDevice(): Boolean = false + // Requests the Google Play account country into [androidPlayStoreCountry] + fun androidLoadPlayStoreCountry() {} val androidApiLevel: Int? get() = null + // The build distributed via Google Play, which has to follow its policies + val androidIsPlayStoreBuild: Boolean get() = false @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true @Composable fun desktopShowAppUpdateNotice() {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 40d7927264..6bfbad52ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -285,7 +285,22 @@ expect fun AttachmentSelection( ) fun MutableState.onFilesAttached(uris: List) { - val groups = uris.groupBy { isImage(it) || isVideoUri(it) } + // The extension is enough to classify every format except .webm, which is just as commonly an + // audio-only container as a video one. An audio-only file has no frame to embed and is sent as a file, + // but that can only be told from the content, so reading it is deferred to a background thread. + // Only done here, where files arrive without the user saying how to send them (drag & drop, paste) - + // an explicitly picked video is still sent as one. + if (uris.none { isWebmUri(it) }) { + attachFiles(uris, emptySet()) + } else { + CoroutineScope(Dispatchers.IO).launch { + attachFiles(uris, uris.filter { isWebmUri(it) && hasVideoTrack(it) }.toSet()) + } + } +} + +private fun MutableState.attachFiles(uris: List, webmVideos: Set) { + val groups = uris.groupBy { isImage(it) || (isVideoUri(it) && (!isWebmUri(it) || it in webmVideos)) } val media = groups[true] ?: emptyList() val files = groups[false] ?: emptyList() if (media.isNotEmpty()) { @@ -298,9 +313,12 @@ fun MutableState.onFilesAttached(uris: List) { private fun isVideoUri(uri: URI): Boolean { val name = getFileName(uri)?.lowercase() ?: return false return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") || - name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") + name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") || + name.endsWith(".webm") } +private fun isWebmUri(uri: URI): Boolean = getFileName(uri)?.lowercase()?.endsWith(".webm") == true + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val maxFileSize = value.maxFileSize @@ -497,8 +515,8 @@ fun ComposeView( } } - fun clearCurrentDraft() { - if (chatModel.draftChatId.value == draftChatId(chat.id, chatScope)) { + fun clearCurrentDraft(forChat: Chat = chat) { + if (chatModel.draftChatId.value == draftChatId(forChat.id, chatScope)) { chatModel.draft.value = null chatModel.draftChatId.value = null } @@ -575,9 +593,10 @@ fun ComposeView( } // TODO [short links] connectCheckLinkPreview - fun checkLinkPreview(): MsgContent { - val msgText = composeState.value.message.text - return when (val composePreview = composeState.value.preview) { + // the state is passed in by a send that must not read the current one - see sendMessageAsync + fun checkLinkPreview(cs: ComposeState = composeState.value): MsgContent { + val msgText = cs.message.text + return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { val parsedMsg = parseToMarkdown(msgText) val url = getMessageLinks(parsedMsg).first @@ -597,6 +616,10 @@ fun ComposeView( composeState.value = composeState.value.copy(inProgress = true) } + // composeState and its inProgress flag are shared between the chats opened in this view, and sending is not cancelled + // when the chat is switched - a send may only clear or reset the state while it still holds the message that was sent + fun composeHasSentMessage(): Boolean = chatModel.chatId.value == chat.id && composeState.value.inProgress + suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() sending() @@ -604,10 +627,10 @@ fun ComposeView( if (contact != null) { withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) - clearState() + if (composeHasSentMessage()) clearState() } - } else { - composeState.value = composeState.value.copy(inProgress = false) + } else withContext(Dispatchers.Main) { + if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false) } } @@ -624,10 +647,10 @@ fun ComposeView( if (contact != null) { withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) - clearState() + if (composeHasSentMessage()) clearState() } - } else { - composeState.value = composeState.value.copy(inProgress = false) + } else withContext(Dispatchers.Main) { + if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false) } } @@ -669,15 +692,19 @@ fun ComposeView( chatModel.channelRelayHostnames.remove(groupInfo.groupId) chatModel.groupMembers.value = relayResults.map { it.relayMember } chatModel.populateGroupMembersIndexes() - clearState() + if (composeHasSentMessage()) clearState() } - } else { - composeState.value = composeState.value.copy(inProgress = false) + } else withContext(Dispatchers.Main) { + if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false) } } - suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?, sign: Boolean = false): List? { - val cs = composeState.value + // toChat is the chat the message was composed in - it differs from the one this view shows only for the live message + // committed by a chat switch, which has no context item, so the forwarding, editing and reporting branches below + // cannot run with a different chat. cs is that send's state, captured before the switch replaced it. + suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?, sign: Boolean = false, toChat: Chat = chat, cs: ComposeState = composeState.value): List? { + // a send for another chat may not write to composeState, even after that chat is opened again - it was handed over + fun composeIsForSend(): Boolean = toChat.id == chat.id var sent: List? var lastMessageFailedToSend: ComposeState? = null val msgText = text ?: cs.message.text @@ -727,8 +754,8 @@ fun ComposeView( fun updateMsgContent(msgContent: MsgContent): MsgContent { return when (msgContent) { - is MsgContent.MCText -> checkLinkPreview() - is MsgContent.MCLink -> checkLinkPreview() + is MsgContent.MCText -> checkLinkPreview(cs) + is MsgContent.MCLink -> checkLinkPreview(cs) is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image) is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) @@ -785,12 +812,12 @@ fun ComposeView( } val liveMessage = cs.liveMessage - if (!live) { + if (!live && composeIsForSend()) { if (liveMessage != null) composeState.value = cs.copy(liveMessage = null) sending() } if (!cs.forwarding || chatModel.draft.value?.forwarding == true) { - clearCurrentDraft() + clearCurrentDraft(toChat) } if (cs.contextItem is ComposeContextItem.ForwardingItems) { @@ -801,6 +828,8 @@ fun ComposeView( if (cs.message.text.isNotEmpty()) { sent?.mapIndexed { index, message -> if (index == sent!!.lastIndex) { + // the current state, not cs: forwarding is never reached from the chat switch, and keeps what was typed + // while it was in flight send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions, sign = sign) } else { message @@ -814,7 +843,7 @@ fun ComposeView( sent = if (updatedMessage != null) listOf(updatedMessage) else null lastMessageFailedToSend = if (updatedMessage == null) constructFailedMessage(cs) else null } else if (liveMessage != null && liveMessage.sent) { - val updatedMessage = updateMessage(liveMessage.chatItem, chat, live) + val updatedMessage = updateMessage(liveMessage.chatItem, toChat, live) sent = if (updatedMessage != null) listOf(updatedMessage) else null } else if (cs.contextItem is ComposeContextItem.ReportedItem) { sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id) @@ -824,7 +853,7 @@ fun ComposeView( val remoteHost = chatModel.currentRemoteHost.value when (val preview = cs.preview) { ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) - is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) + is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview(cs)) is ComposePreview.ChatLinkPreview -> { val linkStr = preview.chatLink.connLinkStr val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr" @@ -915,7 +944,7 @@ fun ComposeView( localPath = file.filePath ) } - val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file, + val sendResult = send(toChat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl, mentions = cs.memberMentions, @@ -932,23 +961,47 @@ fun ComposeView( val wasForwarding = cs.forwarding val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItems)?.fromChatInfo?.id val lastFailed = lastMessageFailedToSend - if (lastFailed == null) { - clearState(live) - } else { - composeState.value = lastFailed - } - val draft = chatModel.draft.value - if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) { - composeState.value = draft - } else { - clearCurrentDraft() + // composeState is shared between the chats opened in this view, and this runs after the send API call, so the user + // could have switched chats or typed another message in the meantime - only the message that was sent may be + // cleared or restored. On Main, so that these checks and changes are not interleaved with the user switching + // chats or typing. + withContext(Dispatchers.Main) { + val chatIsOpen = composeIsForSend() && chatModel.chatId.value == chat.id + // a live message is held in the compose state of the chat it is sent to, but only while that chat is the one open + val liveSend = live || cs.liveMessage != null + val sentMessageInCompose = chatIsOpen && (liveSend || composeState.value.inProgress) + if (sentMessageInCompose) { + if (lastFailed == null) { + clearState(live) + } else { + composeState.value = lastFailed + } + } + val draft = chatModel.draft.value + if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) { + if (sentMessageInCompose) composeState.value = draft + } else { + clearCurrentDraft(toChat) + // liveSend excluded: a failing keystroke send would otherwise write a draft on every attempt + if (!sentMessageInCompose && !liveSend && lastFailed != null) { + // the message was not sent, so it is restored in the chat it was composed in, or kept as its draft if another chat is open + if (chatIsOpen && composeState.value.empty) { + composeState.value = lastFailed + } else if (saveLastDraft) { + chatModel.draft.value = lastFailed + chatModel.draftChatId.value = draftChatId(chat.id, chatScope) + } + } + } } return sent } - fun sendMessage(ttl: Int?, sign: Boolean = false) { + // toChat and composed are for the chat switch, which hands the compose state over to the chat it opened; passing + // toChat without doing that leaves the sent message in the input + fun sendMessage(ttl: Int?, sign: Boolean = false, toChat: Chat = chat, composed: ComposeState? = null) { withLongRunningApi(slow = 120_000) { - sendMessageAsync(null, false, ttl, sign) + sendMessageAsync(null, false, ttl, sign, toChat, composed ?: composeState.value) } } @@ -1313,13 +1366,29 @@ fun ComposeView( KeyChangeEffect(chatModel.chatId.value) { prevChatId -> val cs = composeState.value if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) + // the chat is already switched, so the live message goes to the chat with the id it had before the switch + val liveMessageChat = if (prevChatId == null || prevChatId == chat.id) chat else chatsCtx.getChat(prevChatId) + // if that chat is gone there is nowhere to send it, and it must not be sent to the chat opened instead + // cs is captured on this thread, before the compose state is replaced below + if (liveMessageChat != null) sendMessage(null, toChat = liveMessageChat, composed = cs) else clearState() resetLinkPreview() clearPrevDraft(prevChatId) deleteUnusedFiles() + // the sent message belongs to the chat it was composed in; the chat opened next shows its own draft + val draft = chatModel.draft.value + composeState.value = if (draft != null && chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)) draft + else ComposeState(useLinkPreviews = useLinkPreviews) } else if (cs.inProgress) { clearPrevDraft(prevChatId) - composeState.value = cs.copy(inProgress = false, progressByTimeout = false) + // the message being sent must not be kept in the compose state, it is shared with the chat opened next; + // if it fails to send it is restored in this chat or saved as its draft + clearState() + // clearState() does not load the draft of the chat opened next, and without this it is never shown and is + // dropped when that chat is left + val draft = chatModel.draft.value + if (draft != null && chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)) { + composeState.value = draft + } } else if (!cs.empty) { if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { recState.value = RecordingState.NotStarted diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 77b4c40d7d..68fa25d553 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -183,6 +183,8 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow WhatsNewView(close = close, updatedConditions = showUpdatedConditions) } } 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 index 70f4a1759b..3128c63234 100644 --- 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 @@ -495,6 +495,9 @@ fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration +// Whether the file really contains a video track. Reads container metadata only, without decoding a frame. +expect suspend fun hasVideoTrack(uri: URI): Boolean + fun showWrongUriAlert() { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.non_content_uri_alert_title), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index d3bca178aa..f3006d221b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -836,7 +836,8 @@ fun strConnectTarget(str: String): ConnectTarget? { val links = parsedMd.filter { it.format?.isSimplexLink ?: false } if (links.size == 1) { val fmt = links[0].format as Format.SimplexLink - return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText) + val text = if (fmt.showText != null) fmt.simplexUri else links[0].text + return ConnectTarget.Link(text, fmt.linkType, fmt.simplexLinkText) } if (links.isEmpty()) { val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index f95ffc1961..ea95bc2045 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.model.* @@ -916,6 +917,11 @@ private val versionDescriptions: List = listOf( version = "v7.0", post = null, features = listOf( +// VersionFeature.FeatureView( +// icon = null, +// titleId = MR.strings.v7_0_invest, +// view = { _ -> InvestInSimpleXChatView() } +// ), VersionFeature.FeatureDescription( icon = MR.images.ic_alternate_email, titleId = MR.strings.v7_0_simplex_names, @@ -950,6 +956,58 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } +// private const val WEFUNDER_URL = "https://wefunder.com/simplexchat" +// +// @Composable +// private fun InvestInSimpleXChatView() { +// if (platform.androidIsPlayStoreBuild) { +// LaunchedEffect(Unit) { if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry() } +// if (androidPlayStoreCountry.value != "US") return +// } +// val uriHandler = LocalUriHandler.current +// Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp)) { +// Column(modifier = Modifier.weight(1f)) { +// Row( +// verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.spacedBy(8.dp), +// modifier = Modifier.padding(bottom = 4.dp) +// ) { +// Icon(painterResource(MR.images.ic_redeem), stringResource(MR.strings.v7_0_invest), tint = MaterialTheme.colors.secondary) +// Text( +// generalGetString(MR.strings.v7_0_invest), +// maxLines = 2, +// overflow = TextOverflow.Ellipsis, +// style = MaterialTheme.typography.h4, +// fontWeight = FontWeight.Medium, +// modifier = Modifier.padding(bottom = 6.dp) +// ) +// } +// Text(generalGetString(MR.strings.v7_0_invest_descr), fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp)) +// Row( +// verticalAlignment = Alignment.CenterVertically, +// horizontalArrangement = Arrangement.spacedBy(8.dp), +// modifier = Modifier +// .clickable( +// interactionSource = remember { MutableInteractionSource() }, +// indication = null +// ) { +// uriHandler.openExternalLink(WEFUNDER_URL) +// } +// ) { +// Text(stringResource(MR.strings.v7_0_invest_learn_more), color = MaterialTheme.colors.primary, fontSize = 15.sp) +// Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.v7_0_invest_learn_more), tint = MaterialTheme.colors.primary) +// } +// } +// if (BuildConfigCommon.SIMPLEX_ASSETS) { +// Image( +// painterResource(if (isInDarkTheme()) MR.images.own_stake_light else MR.images.own_stake), +// contentDescription = null, +// modifier = Modifier.width(80.dp) +// ) +// } +// } +// } + @Composable fun CreateUpdateAddressShortLinkView(modalManager: ModalManager) { val clipboard = LocalClipboardManager.current diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c8e040c592..96f36da6d7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -22,7 +22,6 @@ 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.unit.* -import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* @@ -143,7 +142,7 @@ fun HelpAndSupportView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_support_project)) { - if (!BuildConfigCommon.ANDROID_BUNDLE) { + if (!platform.androidIsPlayStoreBuild) { ContributeItem(uriHandler) } if (appPlatform.isAndroid) { 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 7950e7cc1c..ead51b31ea 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2736,6 +2736,9 @@ - opt-in to send link previews.\n- use SOCKS proxy if enabled.\n- prevent hyperlink phishing.\n- remove link tracking. Non-profit governance To make SimpleX Network last. + + + SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg @@ -0,0 +1,4 @@ + + + + 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 index c3b6dc3a4c..768d2f421d 100644 --- 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 @@ -7,6 +7,10 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface +import uk.co.caprica.vlcj.media.Media +import uk.co.caprica.vlcj.media.MediaEventAdapter +import uk.co.caprica.vlcj.media.MediaParsedStatus +import uk.co.caprica.vlcj.media.ParseFlag import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent @@ -255,6 +259,43 @@ actual class VideoPlayer actual constructor( return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) } + // Parsing a local container header takes a few dozen ms, this is only a guard against a stuck parse + private const val PARSE_TIMEOUT_MS = 3000L + + // Reads container metadata to tell whether there is a video track at all, without decoding a frame. + // libvlc signals the end of parsing with an event, so no polling or frame-decoding budget is needed. + suspend fun hasVideoTrack(uri: URI): Boolean = withContext(previewThread.asCoroutineDispatcher()) { + if (!uri.toFile().exists()) return@withContext false + val media = try { + vlcPreviewFactory.media().newMedia(uri.toFile().absolutePath) + } catch (e: Exception) { + Log.e(TAG, "hasVideoTrack unable to create media: ${e.stackTraceToString()}") + null + } ?: return@withContext false + try { + val parsed = CompletableDeferred() + media.events().addMediaEventListener(object: MediaEventAdapter() { + // vlcj maps an unknown status int to null, and a null here would throw on its event thread + override fun mediaParsedChanged(parsedMedia: Media?, newStatus: MediaParsedStatus?) { + parsed.complete(newStatus) + } + }) + if (!media.parsing().parse(PARSE_TIMEOUT_MS.toInt(), ParseFlag.PARSE_LOCAL)) { + return@withContext false + } + if (withTimeoutOrNull(PARSE_TIMEOUT_MS) { parsed.await() } != MediaParsedStatus.DONE) { + media.parsing().stop() + return@withContext false + } + media.info().videoTracks().isNotEmpty() + } catch (e: Exception) { + Log.e(TAG, "hasVideoTrack error: ${e.stackTraceToString()}") + false + } finally { + media.release() + } + } + val playerThread = Executors.newSingleThreadExecutor() private val previewThread = Executors.newSingleThreadExecutor() private val playersPool: ArrayList = ArrayList() 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 index e9924914ef..3293d4f5bd 100644 --- 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 @@ -9,5 +9,6 @@ fun isVideo(uri: URI): Boolean { path.endsWith(".mp4") || path.endsWith(".mpg") || path.endsWith(".mpeg") || - path.endsWith(".mkv") + path.endsWith(".mkv") || + path.endsWith(".webm") } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index eb93e7c510..4535857696 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -42,10 +42,9 @@ actual fun SavePassphraseSetting( } Text( stringResource(MR.strings.save_passphrase_in_settings), - Modifier.padding(end = 24.dp), + Modifier.weight(1f).padding(end = 24.dp), color = Color.Unspecified ) - Spacer(Modifier.fillMaxWidth().weight(1f)) DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, 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 index 3ccb915661..d4c42790d2 100644 --- 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 @@ -255,6 +255,8 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea return VideoPlayer.getBitmapFromVideo(null, uri, withAlertOnException) } +actual suspend fun hasVideoTrack(uri: URI): Boolean = VideoPlayer.hasVideoTrack(uri) + @OptIn(ExperimentalEncodingApi::class) actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encode(this) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b4a7b4319a..93c83de951 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.0 -android.version_code=366 +android.version_name=7.1-beta.0 +android.version_code=369 -android.bundle=false - -desktop.version_name=7.0 -desktop.version_code=155 +desktop.version_name=7.1-beta.0 +desktop.version_code=156 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index cfef4d06c2..9911a2670f 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -370,6 +370,8 @@ var platform: PlatformInterface = object : PlatformInterface {} | `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` | | `androidIsXiaomiDevice()` | `false` | Check device brand | | `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` | +| `androidIsPlayStoreBuild` | `false` | `BuildConfig.PLAY_STORE` | +| `androidLoadPlayStoreCountry()` | no-op | Request the Play account country (google flavor only) | | `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` | | `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog | | `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) | diff --git a/apps/simplex-support-bot/src/messages.ts b/apps/simplex-support-bot/src/messages.ts index c35789d26b..33f7f9ccef 100644 --- a/apps/simplex-support-bot/src/messages.ts +++ b/apps/simplex-support-bot/src/messages.ts @@ -2,7 +2,10 @@ import {isWeekend} from "./util.js" export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI. *Join public groups* at https://simplex.chat/directory or [via directory bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) -Please ask any questions about SimpleX Chat.` + +We just launched [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat)! + +Please ask any questions about SimpleX Chat and about our crowdfunding.` export function queueMessage(timezone: string, grokEnabled: boolean): string { const hours = isWeekend(timezone) ? "48" : "24" diff --git a/assets/multiplatform/resources/MR/images/own_stake@2x.png b/assets/multiplatform/resources/MR/images/own_stake@2x.png new file mode 100644 index 0000000000..fadf1b9599 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake@3x.png b/assets/multiplatform/resources/MR/images/own_stake@3x.png new file mode 100644 index 0000000000..106ee804ca Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake_light@2x.png b/assets/multiplatform/resources/MR/images/own_stake_light@2x.png new file mode 100644 index 0000000000..8f02fa3eaa Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake_light@3x.png b/assets/multiplatform/resources/MR/images/own_stake_light@3x.png new file mode 100644 index 0000000000..0a994849d6 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake_light@3x.png differ diff --git a/cabal.project b/cabal.project index dbb26ad0f2..3ff3ec2713 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ee4dd0d8ded0f66f70a8890ce09d69e3610aa276 + tag: 413f30bee5a14312eca67713b5c47a3537a0c028 source-repository-package type: git diff --git a/docs/ABOUT.md b/docs/ABOUT.md index 44809b9c0c..283b945c3f 100644 --- a/docs/ABOUT.md +++ b/docs/ABOUT.md @@ -16,3 +16,5 @@ SimpleX: "Ask SimpleX team" contact in the app or [this address](https://simplex Email: [chat@simplex.chat](mailto:chat@simplex.chat). You can use PGP to encrypt email messages using our key from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and making your key available for a secure reply. You can follow our updates on social media: [X/Twitter](https://x.com/simplexchat), [Reddit](https://www.reddit.com/r/SimpleXChat/), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://primal.net/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). + +Subscribe to our email updates via [this page](https://use.simplex.chat/email-updates) - they are sent via MailChimp as plaintext emails without any tracking. diff --git a/docs/LINKS.md b/docs/LINKS.md index 46de314a75..b45c9fb5c4 100644 --- a/docs/LINKS.md +++ b/docs/LINKS.md @@ -1,5 +1,21 @@ # Links to Community Publications +## SimpleX Chat: Private Monero Communities — and Now a Chance to Invest + +Monerica + +Article + +SimpleX Chat calls itself “the messaging network that can’t identify you,” and that is not marketing hyperbole — it is the actual design. For a privacy community built around Monero, that combination of unlinkable messaging and unlinkable money is a natural fit. This post looks at why SimpleX matters, the growing set of private Monero communities already running on it, and the news that users can now buy a stake in the project itself through an equity crowdfunding round on Wefunder. + +Image: simplex-monero-investing.jpg + +Language: English + +Date: Aug 10, 2026 + +https://blog.monerica.com/articles/simplex-chat-private-monero-communities + ## SimpleX Chat: Product Showcase - Removing User Identifiers From Messaging Help Net Security @@ -200,19 +216,19 @@ https://opennet.ru/65337/ ## Vitalik Buterin Donates $765K in Ethereum to Privacy Messaging Apps -Yahoo Finance +Decrypt News -Yahoo Finance reports that Vitalik Buterin donated approximately $765,000 in Ethereum to privacy messaging apps Session and SimpleX. Buterin praised both apps for advancing permissionless account creation and metadata privacy, while acknowledging neither is perfect and both need improvements in user experience and security. +Decrypt reports that Vitalik Buterin donated approximately $765,000 in Ethereum to privacy messaging apps Session and SimpleX. Buterin praised both apps for advancing permissionless account creation and metadata privacy, while acknowledging neither is perfect and both need improvements in user experience and security. -Image: yahoo-finance-buterin.jpg +Image: decrypt-buterin.jpg Language: English Date: Nov 2025 -https://finance.yahoo.com/news/vitalik-buterin-donates-765k-ethereum-190102367.html +https://decrypt.co/350253/vitalik-buterin-donates-765k-in-ethereum-to-privacy-messaging-apps ## Vitalik Buterin Supports Privacy-Focused Messaging Platforms With Significant Ethereum Donation @@ -2543,24 +2559,6 @@ Date: May 22, 2022 https://www.youtube.com/watch?v=N0prtSOyeUU -## Kostiantyn Korsun: Zaluzhnyi and Messengers - -(Kostyantyn Korsun: Zaluzhnyy i mesendzhery) - -Tverezo.info - -Article - -This Ukrainian article, written by Kostyantyn Korsun, discusses General Zaluzhny's essay on technology in modern warfare and the Ukrainian military's widespread reliance on Signal for encrypted communications despite formal prohibitions. While focused on Signal's role in military contexts and the US Defense Secretary's controversy over using Signal for classified data, the article addresses the broader topic of encrypted messengers in sensitive operational environments. - -Image: tverezo-korsun-zaluzhnyi.jpg - -Language: Ukrainian - -Date: 2025 - -https://tverezo.info/post/205151 - ## Top 10 Most Secure Messaging Apps in 2024 (Top 10 mest sikre besked-apps i 2024) @@ -3780,6 +3778,8 @@ Review This Monerica directory page lists several Monero-focused SimpleX Chat communities spanning multiple languages and regions, including groups for Monero discussion in Slovenian, German, Italian, and Hebrew. It includes an automated bot that sends hourly Monero price updates via SimpleX. +Monerica is established in 2022. + Image: monerica-simplex-communities.jpg Language: English @@ -3820,22 +3820,6 @@ Date: 2024 (estimated) https://www.anarsec.guide/posts/e2ee/ -## Join Beginner Privacy on SimpleX - -Beginner Privacy - -Community - -The Beginner Privacy community selected SimpleX Chat as their primary communication platform for its strong privacy features. The page provides setup instructions for beginners across Linux, Mac, Windows, iOS, and Android, emphasizing accessibility through both graphical and command-line interfaces. - -Image: beginner-privacy-simplex-group.jpg - -Language: English - -Date: 2025 (estimated) - -https://beginnerprivacy.com/about/join-simplex-group/ - ## Sofwul.cz: E-Commerce with SimpleX Contact Sofwul diff --git a/docs/contributing/PROJECT.md b/docs/contributing/PROJECT.md index 3f7e6e0e54..40417a6539 100644 --- a/docs/contributing/PROJECT.md +++ b/docs/contributing/PROJECT.md @@ -68,14 +68,15 @@ The project uses several custom forks managed via `cabal.project`: ```bash cd apps/multiplatform -# Build Android debug APK -./gradlew assembleDebug +# Build Android debug APK; `foss` ships to F-Droid/GitHub, `google` adds Play Billing. +# The aggregate tasks fail by design, see apps/multiplatform/README.md +./gradlew assembleFossDebug # Build desktop ./gradlew :desktop:packageDistributionForCurrentOS # Run Android tests -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest ``` ### iOS diff --git a/docs/links/images/yahoo-finance-buterin.jpg b/docs/links/images/decrypt-buterin.jpg similarity index 100% rename from docs/links/images/yahoo-finance-buterin.jpg rename to docs/links/images/decrypt-buterin.jpg diff --git a/docs/links/images/simplex-monero-investing.jpg b/docs/links/images/simplex-monero-investing.jpg new file mode 100644 index 0000000000..2c04c8d292 Binary files /dev/null and b/docs/links/images/simplex-monero-investing.jpg differ diff --git a/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md b/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md new file mode 100644 index 0000000000..2fe40755e8 --- /dev/null +++ b/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md @@ -0,0 +1,33 @@ +# Connecting via a SimpleX link written as a markdown hyperlink + +## Problem + +Pasting a short SimpleX link written as a markdown hyperlink — `[label](https://smp6.simplex.im/a#...)` — into the chat list search, the new chat sheet search, or "Tap to paste link" fails with "Invalid connection link" instead of connecting. + +## Cause + +`markdownP` parses such a link into a single fragment whose `format` is `SimplexLink` but whose `text` is the whole markdown source: + +``` +[{"format":{"type":"simplexLink","showText":"label","linkType":"contact", + "simplexUri":"simplex:/a#...?h=smp6.simplex.im","smpHosts":["smp6.simplex.im"]}, + "text":"[label](https://smp6.simplex.im/a#...)"}] +``` + +`strConnectTarget` returns that `text` as the string to connect with. For a bare link `text` is the link, so it works; for a hyperlink it is `[label](link)`, which the core rejects as `InvalidConnReq`. + +## Design + +Use `simplexUri` — the link the parser already resolved — when the fragment came from the hyperlink parser, and keep using `text` otherwise: + +``` +text = if showText != null then simplexUri else text +``` + +`showText` is an exact discriminator, not a heuristic: `simplexUriFormat` is called with `Just t` only from `sowLinkP` (the hyperlink parser) and with `Nothing` from `wordMD` (bare link). Gating on it leaves every bare-link path unchanged. + +This also matches how the chat item renderer already resolves the same format — `TextItemView.kt` takes `simplexUri`, never the fragment `text`, when `showText` is set. `strConnectTarget` was the outlier. + +## Scope + +Short links only. `sowLinkP` rejects a full link inside a hyperlink (`fail "full SimpleX link in hyperlink"`), so `[label](full-link)` yields no formatting at all and never reaches this code — it stays treated as search text, as before. Bare full links are unaffected. diff --git a/images/github-banner.jpg b/images/github-banner.jpg index f7a0d730b8..ef3cb5e6f5 100644 Binary files a/images/github-banner.jpg and b/images/github-banner.jpg differ diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 01d88ab770..5dd0bd0417 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.10.3", + "version": "0.11.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 76833f70c8..7409468212 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.0.0", + "version": "7.1.0-beta.0", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.10.3", + "@simplex-chat/types": "^0.11.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index e0685e0123..c5d2e5f774 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.0.0'; +const RELEASE_TAG = 'v7.1.0-beta.0'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index e1cbccc2da..abb6fed421 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.0.0" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.0.0" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b0" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.0" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/plans/2026-07-25-fix-inflight-send-writes-other-chat-compose.md b/plans/2026-07-25-fix-inflight-send-writes-other-chat-compose.md new file mode 100644 index 0000000000..7a27fdd9d1 --- /dev/null +++ b/plans/2026-07-25-fix-inflight-send-writes-other-chat-compose.md @@ -0,0 +1,244 @@ +# Fix: message being sent leaks into another chat's compose/draft, and erases what is typed there + +Branch: `nd/fix-inflight-send-writes-other-chat-compose` (off `origin/stable`) +Date: 2026-07-25 +PR: #7308 + +Line references are against `origin/stable` at `8dc387cb5`, with this fix +applied. Android and desktop only +(`multiplatform/.../views/chat/ComposeView.kt`); iOS has the same defect +but is not addressed here. + +## Problem + +Two reported symptoms, one cause. Both need a send that is still in +flight when the chat is switched (slow network, large file, or the send +just hanging with the progress circle showing): + +1. **The message ends up in another chat's draft.** Reply to a message + (or just type), press send, switch to another chat while it is + sending: the text *and the reply context* appear in that chat's input, + and leaving it saves them as that chat's draft. No forwarding + involved. +2. **A late send erases what you typed.** Press send, the progress circle + keeps spinning, switch to another chat and back, type a new message — + when the original send finally succeeds, the newly typed message is + erased. + +## Cause + +The compose state is shared, and the send outlives the chat: + +- `ChatView.kt:134` — one `MutableState` per `ChatView` + instance, `rememberSaveable` with no keys, reused for every chat that + the view displays. +- `Utils.kt:43-46` — `withLongRunningApi` launches on + `CoroutineScope(Dispatchers.Default)`, a standalone scope with no tie + to the composition or to the chat, and `sendMessage` + (`ComposeView.kt:972-976`) uses it. Leaving the chat never cancels an + in-flight send. + +Two writes then act on the wrong chat: + +- **On the chat switch** — `ComposeView.kt:1343-1347`: the `cs.inProgress` + branch used to keep the message in the shared compose state + (`composeState.value = cs.copy(inProgress = false, progressByTimeout = false)`) + and only cleared the *previous* chat's saved draft. The text and the + quote were therefore sitting in the input of the chat opened next, and + `ComposeView.kt:1348-1358` (`!cs.empty`) then saved them as *that* + chat's draft on the next switch. Symptom 1. +- **When the send completes** — `ComposeView.kt:943-968`, running in the + detached coroutine after the switch: `clearState(live)` on success, or + `composeState.value = lastFailed` on failure, where `lastFailed = + cs.copy(inProgress = false, preview = preview)` + (`ComposeView.kt:729`) **keeps `contextItem`, i.e. the reply**. On + success this wipes whatever is in the input now — including a message + typed after coming back (symptom 2); on failure it dumps the old + message into whichever chat is open (symptom 1 again). + +The same function was already inconsistent about which chat it acts on: +its draft bookkeeping (`clearCurrentDraft()`, and the forwarding +condition) uses the **captured** `chat` — the chat the message was +composed in — while its `composeState` writes hit whatever chat is +displayed at that moment. + +## Fix + +Two changes, both in `ComposeView.kt`. + +**1. Do not keep the message being sent in the shared compose state** +(`ComposeView.kt:1343-1347`). On switching away with a send in flight the +compose state is cleared, so nothing leaks into the chat opened next: + +```kotlin +} else if (cs.inProgress) { + clearPrevDraft(prevChatId) + // the message being sent must not be kept in the compose state, it is shared with the chat opened next; + // if it fails to send it is restored in this chat or saved as its draft + clearState() +} +``` + +`clearState()` is used rather than assigning an empty `ComposeState` so that +the link preview state is reset too (`pendingLinkUrl` still points at the +sent message's link, and its fetch would otherwise set a preview on the +input of the chat opened next), and so that the attachment size limit is +carried over the same way as everywhere else. + +In-flight content is deliberately **not** saved as a draft here: the +message has been submitted and will most likely be sent, and a draft is +for messages that are not sent yet. + +`clearState()` alone would leave the chat opened next with an empty input +even when it has a draft: this branch, like the live message one above it, +returns before the branch that loads a draft +(`chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)`), +so that draft was never shown - and, being still in the slot but not in +any compose state, it was then dropped by `clearPrevDraft` on the next +chat switch. It is loaded here instead. This is not the one-slot +limitation below: nothing else is competing for the slot, the draft is +simply lost. + +**2. Only touch the compose state if it still holds the message that was +sent** (`ComposeView.kt:936-968`): + +```kotlin +withContext(Dispatchers.Main) { + val chatIsOpen = chatModel.chatId.value == chat.id + val liveSend = live || cs.liveMessage != null + val sentMessageInCompose = chatIsOpen && (liveSend || composeState.value.inProgress) + if (sentMessageInCompose) { + if (lastFailed == null) { + clearState(live) + } else { + composeState.value = lastFailed + } + } + val draft = chatModel.draft.value + if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) { + if (sentMessageInCompose) composeState.value = draft + } else { + clearCurrentDraft() + if (!sentMessageInCompose && lastFailed != null) { + // the message was not sent, so it is restored in the chat it was composed in, or kept as its draft if another chat is open + if (chatIsOpen && composeState.value.empty) { + composeState.value = lastFailed + } else if (saveLastDraft) { + chatModel.draft.value = lastFailed + chatModel.draftChatId.value = draftChatId(chat.id, chatScope) + } + } + } +} +``` + +Both the checks and the changes run on `Dispatchers.Main` (the block has +no suspension points), so they cannot be interleaved with the user +switching chats or typing - `KeyChangeEffect`, which does change 1, runs +there too. + +`inProgress` is the marker that the compose state is still the submitted +message: it is set by `sending()` (`ComposeView.kt:596-598`), preserved by +`copy` while sending (the only other write during a send is +`progressByTimeout` at `ComposeView.kt:1610-1617`), reset when switching +away (change 1), and never set by typing a new message. So a chat switch +*or* newly typed text both make the guard false. + +A **failed** send is different from an in-flight one - the message was not +sent, so it is an unsent message. It is put back into the input if that +chat is open and nothing else is being composed there, and kept as that +chat's draft otherwise, so it never appears in another chat (see the +limitations below for when it is still dropped). Staying in the chat is +unaffected: the guard is true there +and the failed message is restored into the input as before, keeping +"preserving long message when failed to send" (`e61babdc8`) working. + +Deliberately unchanged: + +- The condition of the forwarding branch. Gating the whole branch would + send a forward that completed after the user left to `clearCurrentDraft()` + instead, **deleting** the destination chat's draft that the branch + exists to preserve - only the compose write inside it is gated. +- Live message sends (`live`, or `cs.liveMessage != null` for the send + that finalises a live message when leaving the chat, `ComposeView.kt:1338-1342`), + as long as their chat is the one open. They never call `sending()`, so a + guard based on `inProgress` would change their behaviour: failed live + sends would stop restoring and would write a draft on every failing + keystroke send. That is why `liveSend` is an alternative to `inProgress` + inside the guard, and why it is excluded from the restore/draft branch - + not gating it there would produce exactly that draft-per-keystroke. + + What they are **not** exempt from is `chatIsOpen`. An earlier revision + had `live || cs.liveMessage != null` outside it, which holds only while + a live message is always sent to the chat that is open. #7323 removes + that: the live message committed by a chat switch is sent to the chat it + was composed in, while this view already shows another one, so an + unguarded clause here would clear *that* chat's compose state - the leak + this fix exists to prevent. Standalone this changes nothing except a + live send that completes after its chat was left, which now leaves the + opened chat alone. `sendMessageAsync` reads `composeState` inside the + coroutine (`ComposeView.kt:684`), so that branch cannot clear the state + itself without racing the send; #7323 adds the `composed` parameter that + makes the captured state explicit. + +**3. The same check where the flag is shared** (`ComposeView.kt:600-602` +and the three senders that connect a prepared chat). They call the same +`sending()`, so an unguarded `clearState()` or `inProgress` reset from +one of them corrupts the state of a send started in the chat opened next. + +## Behaviour after the fix + +| situation | before | after | +| --- | --- | --- | +| send, stay in chat, succeeds | input cleared | input cleared (unchanged) | +| send, stay in chat, fails | message restored in input | message restored in input (unchanged) | +| send, switch chats, succeeds | message left in the other chat's input, saved as its draft | other chat untouched | +| send, switch chats, fails | message dumped into the other chat's input | message restored in the chat it was composed in, or kept as its draft | +| send hangs, switch away and back, type, then it succeeds | typed message erased | typed message kept | +| forward send, still in destination chat | destination chat's draft restored | unchanged | +| live message sent on leaving the chat | compose state cleared by the send | unchanged | + +## Limitations + +Kept deliberately, to not grow the change: + +- A message that failed to send is dropped, rather than kept, when the + "Message draft" privacy setting is off, when the destination chat of a + failed forward already has a draft (its own draft is preserved + instead), and when the single draft slot is later taken by another + chat - drafts are one global slot, so the last write wins. +- The three senders that connect a prepared chat share the same + `sending()` flag, so they use the same check (`ComposeView.kt:604-616`, + `618-640`, `659-685`). Without it a connect completing after the chat + was switched would clear `inProgress` for a send started in the chat + opened next, and that sent message would then stay in the input. They + have no failed-message restore, so their typed message is dropped when + the chat is switched instead of being carried into the next chat. +- Typing in the same chat while its own send is in flight is still + cleared when the send completes: `inProgress` is preserved by `copy`, + so the guard stays true. Unchanged from before, and different from the + reported symptom, which needs the chat to be switched. +## Verification + +- `./gradlew :common:compileKotlinDesktop` — passes. +- Manual (needs a slow or failing send — e.g. airplane mode, or a large + file). On desktop any chat switch exercises it; on Android only an + in-place switch does (member info → open chat), because leaving to the + chat list destroys the view: + 1. Reply + type in A, send, switch to B while sending. B's input must + stay empty; leaving B must not create a draft in B. If the send + failed, A must hold the message (with the reply) as its draft. + 2. Send in A with the network off so the circle keeps spinning, switch + to B and back to A, type a new message, restore the network. The + typed message must survive the old send completing. + 3. Regression: ordinary send in A (input clears), failed send while + staying in A (message comes back in the input), forward into a chat + that has a draft (draft restored after sending). + +Rebased onto the scope-aware draft ids introduced by #7309: the draft +written here for a message that failed to send uses +`draftChatId(chat.id, chatScope)`, like every other draft write. + +Related: `plans/2026-07-25-fix-forward-moves-draft-to-target-chat.md` +(PR #7307) — different cause (stale `chat` captured by the desktop +`onDispose`), same shared-compose-state design. diff --git a/plans/2026-07-29-fix-live-message-sent-to-wrong-chat.md b/plans/2026-07-29-fix-live-message-sent-to-wrong-chat.md new file mode 100644 index 0000000000..bd37361dc5 --- /dev/null +++ b/plans/2026-07-29-fix-live-message-sent-to-wrong-chat.md @@ -0,0 +1,248 @@ +# Fix: live message is sent to the chat opened after switching chats + +Branch: `nd/fix-live-message-sent-to-wrong-chat` (off `origin/stable`) +Date: 2026-07-29 + +Line references are against `origin/stable` at `970ef8932`, with this fix +applied. Android and desktop (`commonMain/ComposeView.kt`). + +## Problem + +Typing a live message and switching to another chat sends that message to +the chat that was opened, without the user sending anything. Reported on +desktop, where every chat switch reuses the same view. + +## Cause + +A live message is committed when the chat is switched +(`ComposeView.kt:1353-1369`), which before this change was: + +``` + if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) +``` + +`KeyChangeEffect` is `LaunchedEffect(key1) { block(prev) }` +(`Utils.kt:683-698`), so when the key changes, `remember(key1)` rebuilds +it from the lambda of the composition that is running *now* - and by then +`chatModel.chatId` is already the new chat, `ChatView` has recomposed +`ComposeView` with the new `chat`, and the block that runs captured that +one. + +`sendMessage(null)` → `sendMessageAsync` → `send(chat, ...)` uses the +captured `chat`, while the message content comes from `composeState`, +which is shared between the chats opened in this view. So the content of +the chat that was left is sent to the chat that was opened: + +- `liveMessage.sent == false` - a new message is created in the wrong + chat, which is what is seen; +- `liveMessage.sent == true` - `apiUpdateChatItem` is called with the new + chat's type and id and the item id from the previous chat, which the + backend cannot resolve. + +The same mismatch made the post-send `clearCurrentDraft()` clear the +draft of the chat opened after the switch, deleting a draft that was +never sent. + +## Fix + +`sendMessageAsync` and `sendMessage` take the chat the message was +composed in, defaulting to the chat this view shows +(`ComposeView.kt:685-694`, `988-993`). Only what a live message can reach +uses it: the message send, the update of an already sent live message, +and the two places that clear the draft after sending. Live messages have +no context item (`SendMsgView.kt:156-165` only offers the button when the +compose is empty and has none), so the forwarding, editing and reporting +branches cannot run with a chat other than the view's and keep using +`chat` - the parameter is not threaded through them. + +The chat switch resolves the chat by the id it had before the switch: + +```kotlin +val liveMessageChat = if (prevChatId == null || prevChatId == chat.id) chat else chatsCtx.getChat(prevChatId) +// if that chat is gone there is nowhere to send it, and it must not be sent to the chat opened instead +if (liveMessageChat != null) sendMessage(null, toChat = liveMessageChat, composed = cs) else clearState() +``` + +`prevChatId == chat.id` keeps the view's own chat, which is what secondary +(member support) chat views need - they share the group's chat id, and +only their `chat` carries the scope. + +If the previous chat can no longer be found the message is not sent at +all, and the compose state is cleared so it does not leak into the chat +that was opened. Sending it to the chat that is open now is the defect +being fixed, so it is not used as a fallback. + +### Handing the compose state over to the opened chat + +Sending to the right chat is not enough on its own: `composeState` is +shared between the chats opened in this view, and this is the only branch +of `KeyChangeEffect` that neither resets it nor loads the opened chat's +draft - the branch that loads a draft (`else if (chatModel.draftChatId +.value == draftChatId(chatModel.chatId.value, chatScope) ...)`) is later +in the same `if` chain and cannot be reached. So the live message stayed +in the compose state of a view that now shows another chat, and that +chat's draft was never read. + +`sendMessageAsync` then made it visible. It runs on `Dispatchers.Default`, +so its writes land after the switch: + +```kotlin +val liveMessage = cs.liveMessage +if (!live) { + if (liveMessage != null) composeState.value = cs.copy(liveMessage = null) // the whole composed state + sending() // and its spinner +} +``` + +The opened chat's input showed the text composed in the previous one until +the send completed and `clearState()` emptied it; the draft it should have +shown was still in the model, and the next switch away dropped it. This +predates this fix - without it the same writes happen, and there +`clearCurrentDraft()` resolves to the opened chat and deletes its draft +outright. + +Four changes, all following from "this send no longer owns the compose +state": + +- `sendMessageAsync` takes its `cs` as a parameter defaulting to + `composeState.value`, and `sendMessage` takes `composed: ComposeState? = + null`, so only the chat switch passes a state and every other sender + still reads it inside the coroutine, exactly where the send read it + before. The chat switch + captures it on the main thread before replacing it - without that the + send would read the compose state of the chat that was opened and send + *its draft* to the previous chat. +- `checkLinkPreview` takes that state too. It re-read `composeState` + rather than what was passed in, and it is reached by every text live + message through `updateMsgContent`, so with the compose state handed + over it would have rebuilt the message from the opened chat's draft, or + from nothing - overwriting the live message instead of committing it. + Only the calls a live message can reach pass the state. The forwarding + call site keeps reading the current one - it is unreachable from the + chat switch, and `forwardItem` suspends before it, so passing the + captured state there would drop what was typed while the forward was in + flight. The three senders that connect a prepared chat keep reading the + current one too. +- Every `composeState` write in `sendMessageAsync` is guarded by + `composeIsForSend()` (`toChat.id == chat.id`): directly for the two at + the start, and through `chatIsOpen` for the clear/restore at the end, + which #7308 already routes through `sentMessageInCompose`. It compares + the two chats rather than checking which one is open, so the send made + by a chat switch never takes the compose state back, not even if that + chat is opened again before the send completes. + `clearCurrentDraft(toChat)` is already keyed on the chat and needs no + guard. Whether the *view's own* send may still write when its chat has + been switched away is #7308's question, not this one's. +- The chat-switch branch then resets `composeState` to the opened chat's + draft, or to an empty state, like the branches below it do. + +## Blast radius + +`toChat` defaults to the chat this view shows, so every other send passes +no chat: the send button (`SendMsgView.kt`), the live updates while typing +(`sendMessageAsync(live = true)`), forwarding, editing and reporting. For +all of them `composeIsForSend()` is true, so every guard added here is a +no-op and they behave exactly as before. Only the send started by the chat +switch passes a different chat, and only the branches it can reach were +changed. + +The one change not behind that guard is `checkLinkPreview` reading the +state passed in. It matters only where the two can differ, which is after +a suspension: the forwarding branch waits on `forwardItem`, so that call +site deliberately keeps reading the current state (it is unreachable from +the chat switch anyway). The other call sites are reached with nothing +suspending since the state was captured. + +The live message update loop is not affected: it is started once +(`SendMsgView.kt:523-559`) with the `::updateLiveMessage` reference of the +composition in which live mode started, so its updates already go to the +chat the message belongs to. It exits because the chat switch replaces the +compose state with the opened chat's, which has no `liveMessage` - on the +main thread, as the chat is switched, rather than when the send completes +as before. Only that send was created fresh on every composition, which is +why it was the one going to the wrong chat. + +Not covered, and unchanged: a live message in a member support chat that +is closed without changing the chat id is never committed - the effect +that commits it is keyed on the chat id, which does not change when that +view is closed. + +`chatsCtx.getChat` searches the context's own list, and a secondary +context (member support, reports) is built with an empty one, so there it +can only return null. That branch is not reached from a support chat in +practice - it shares the group's chat id, so `prevChatId == chat.id` holds +and the view's own `chat` is used - and if it ever were, the message is +discarded rather than sent to the chat that was opened, which is the +behaviour intended for "the chat is gone" anyway. + +## Verification + +- `./gradlew :common:compileKotlinDesktop` — passes. +- Manual: + 1. Start a live message in **A**, type, and switch to **B** while + typing. The message must appear in **A**; nothing is sent in **B**, + and B's input and draft are untouched. + 2. Repeat with a draft already saved in **B** - it must still be there + after the switch. This is the case that was found failing: B showed + the text composed in A, then emptied when the send completed, and B's + draft was dropped on the next switch. Watch B's input from the moment + of the switch, not only after the send finishes. + 3. Slow or failing send (network off) while doing 1 and 2, so the window + between the switch and the send completing is long enough to type in + **B** - what is typed there must survive the send completing. + 4. The live message must carry a **link preview**: type a URL in **A**, + let the preview load, then switch. The message committed to A must be + the text that was composed - not the opened chat's draft, and not + empty. Every text live message is rebuilt through + `updateMsgContent` -> `checkLinkPreview`, so this is what breaks if + that one stops reading the state it was given. + 5. Switch **back**: live message in A, switch to B, return to A and type + something new before the send completes. What is typed in A must + survive - the send handed the compose state over at the switch and + must not take it back. + 6. Regressions: an ordinary send goes to the chat it was typed in; + forwarding still targets the chat it was forwarded to, and text typed + while a forward is in flight is still appended to it; reporting a + message still reports it in the chat it belongs to; sending in a + member support chat still goes to that scope. + +## Merged with #7308 + +#7308 (a send that is still in flight when the chat is switched) landed in +`stable` first, so this branch was merged with it. Both changed the end of +`sendMessageAsync`, and the two guards are **not** the same rule - the +merge keeps both: + +- here, `composeIsForSend()` = `toChat.id == chat.id` - is this send for + the chat this view shows, or for another one; +- in #7308, `chatIsOpen` = `chatModel.chatId.value == chat.id` - is the + chat this view shows still the one open. + +`chatIsOpen` becomes the conjunction, +`composeIsForSend() && chatModel.chatId.value == chat.id`. Where `toChat` +is `chat` - every send but the one made by a chat switch - that reduces to +#7308's own check, so its behaviour is unchanged. + +Nothing else in that block had to move. #7308 already routes both compose +writes through `sentMessageInCompose`, which derives from `chatIsOpen`, so +guarding `chatIsOpen` guards them; the rest of the change there is one +call site taking `toChat`, `clearCurrentDraft`. The draft id a failed +message is saved under keeps using `chat`: that branch is behind +`!liveSend`, which the send made by a chat switch never satisfies, so +`toChat` is always `chat` where it is read. + +An earlier revision of this note said that #7308's `cs.liveMessage != null` +clause "already covers the send made by the chat switch". **It did not.** +At the time that clause sat outside the `chatIsOpen` check: + +```kotlin +val sentMessageInCompose = live || cs.liveMessage != null || (chatIsOpen && composeState.value.inProgress) +``` + +which is correct only while a live message is always sent to the chat that +is open - the assumption this fix removes. Read as written, the clause +*exempts* the chat-switch send from the very guard that protects the +opened chat, and a merge that followed it reintroduced the leak described +above. #7308 shipped with the live clauses moved inside `chatIsOpen`, +which was a no-op on its own branch and is what makes this merge work. diff --git a/plans/2026-07-31-fix-db-passphrase-toggle-clipped.md b/plans/2026-07-31-fix-db-passphrase-toggle-clipped.md new file mode 100644 index 0000000000..16767d62aa --- /dev/null +++ b/plans/2026-07-31-fix-db-passphrase-toggle-clipped.md @@ -0,0 +1,124 @@ +# Fix "Save passphrase in settings" toggle unreachable on desktop + +## Symptom + +On 7.0 desktop (reproduced on the Linux AppImage and on Windows), in +Chat data → Database passphrase & export → Database passphrase, the +"Save passphrase in settings" toggle cannot be switched off. The reporter sees +the switch sitting slightly past the right edge of the section card. Because the +setting stays on, the passphrase remains stored in `settings.properties` and the +app never prompts for it on start — on desktop that file holds the passphrase in +clear text, since `Cryptor.desktop.kt` is an identity implementation. + +This is distinct from the case where the switch is *rendered disabled* +(`DatabaseEncryptionView.kt:127`, `enabled = (!initialRandomDBPassphrase && !progressIndicator) || migration`), +which is intended behaviour for a database still using the initial random +passphrase. The reports here are from users who set their own passphrase, so +`initialRandomDBPassphrase == false` and the switch is enabled — just not +reachable. + +## Root cause + +1. `SavePassphraseSetting` is hand-rolled in both platform actuals + (`DatabaseEncryptionView.desktop.kt:43-53`, `.android.kt:43-53`) and is the + only toggle row in the app whose label carries no `weight`: + + ```kotlin + Text(stringResource(MR.strings.save_passphrase_in_settings), Modifier.padding(end = 24.dp)) + Spacer(Modifier.fillMaxWidth().weight(1f)) + DefaultSwitch(checked = useKeychain, onCheckedChange = onCheckedChange, enabled = enabled) + ``` + + `Row` measures unweighted children first against the full available width, then + divides what is left among weighted ones. A label that does not comfortably fit + consumes the remainder, the weighted `Spacer` collapses to zero, and + `DefaultSwitch` is placed past the row's right edge. + +2. Every other toggle row goes through `SettingsActionItemWithContent` + (`SettingsView.kt:380`), which gives the label `Modifier.weight(1f)`. There the + label truncates and the trailing control keeps its size and position, so the + same string length is harmless. + +3. Before #6777 this row had enough slack and no clipping. `SectionView` was a + plain `Column` with no horizontal inset, and `SectionItemView` used + `DEFAULT_PADDING` (20.dp) per side — 348.dp of content width on the desktop + start pane (`DEFAULT_START_MODAL_WIDTH` = 388.dp). Any overflow still drew and + still received pointer events. + +4. #6777 introduced `LocalCardScreen` / `CardColumnLayout` (`Section.kt`), which + wraps section content in `Modifier.padding(horizontal = CARD_PADDING /* 18.dp */)` + … `.clip(SectionCardShape)`, and switches `itemHPadding` from `DEFAULT_PADDING` + to `CARD_PADDING`. `DatabaseEncryptionView` is opened with `cardScreen = true` + (`DatabaseView.kt:235`), so its row content width drops 348.dp → **316.dp**. + +5. `Modifier.clip` clips pointer input as well as drawing. The displaced switch is + therefore both cut off visually and unhittable — the toggle stops working rather + than merely looking wrong. + +Budget arithmetic on the desktop start pane: fixed cost in the row is ~96.dp +(24 icon + 8 spacer + 24 label end-padding + ~40 switch), leaving ~220.dp for a +27-character label at 16.sp, which needs ~215.dp in English. Borderline at 100% +font scale and over budget as soon as the label is longer — a longer localization, +or a larger font size, since the label scales with `fontSizeSqrtMultiplier` while +`CARD_PADDING` does not. + +The widths above are derived from the layout constants, not measured against a +running client; the reporter's observation that the switch sits slightly past the +card edge is what confirms the row overflows in practice. + +## Fix + +Move the weight onto the label and drop the weighted spacer, in both actuals: + +```kotlin +Text(stringResource(MR.strings.save_passphrase_in_settings), Modifier.weight(1f).padding(end = 24.dp)) +DefaultSwitch(checked = useKeychain, onCheckedChange = onCheckedChange, enabled = enabled) +``` + +The label now truncates instead of displacing the switch, matching what +`SettingsActionItemWithContent` does for every other toggle row. + +The spacer has to go: leaving both the label and the spacer weighted would split +the remaining space between them and starve the label instead, which trades one +layout bug for another. + +## Why this fix and not alternatives + +- **Widening the card or shrinking `CARD_PADDING`** would buy back the ~32.dp lost + in #6777, but only until the next longer localization or font-size step. The row + would stay the one place in the app where a long label can push a control out of + reach. +- **Removing `clip` from `CardColumnLayout`** would restore clickability of + overflowing content, but the clip is what gives section cards their rounded + corners; dropping it would regress the design and leave the switch drawn outside + its card. +- **Shortening the string** is a translation-wide problem, not a fix, and does not + help at larger font sizes. + +## Impact + +- Desktop and Android only. Both actuals carry the identical defect; Android's row + is in fact narrower still (~288.dp on a 360.dp-wide screen), so it is affected at + least as much — it simply has not been reported. +- iOS is unaffected: `DatabaseEncryptionView.swift` uses a SwiftUI `Toggle` inside + `settingsRow`, where the label truncates and the toggle cannot be displaced. The + `initialRandomDBPassphrase` disabled-state logic is the same on iOS + (`DatabaseEncryptionView.swift:80`) and is unchanged by this fix. +- Users already stuck in the bad state have `StoreDBPassphrase=true` in + `settings.properties` with the passphrase stored alongside it. After this fix + they can turn the setting off in the UI, which removes the stored passphrase via + `removePassphraseFromKeyChain` and restores the prompt on start. +- No behaviour change beyond the row layout: no logic, preference, or string was + touched. + +## Verification + +- `bash ~/build/linux.sh` on this branch: cold `dist-newstyle`, `libsimplex.so` + rebuilt from master's sources, `:common:compileKotlinDesktop` executed, + `BUILD SUCCESSFUL`, AppImage produced. +- `bash ~/build/android.sh` on this branch: `BUILD SUCCESSFUL`, arm64-v8a debug APK + produced (native libs are the prebuilt ones, so this exercises the Kotlin change + only). +- Not done: the rendered row has not been checked in a running client. Worth + confirming at a raised font size and in a locale with a longer label, which is + the case that made the overflow visible in the first place. diff --git a/plans/2026-08-07-webm-video-detection.md b/plans/2026-08-07-webm-video-detection.md new file mode 100644 index 0000000000..e8d4ff1e08 --- /dev/null +++ b/plans/2026-08-07-webm-video-detection.md @@ -0,0 +1,19 @@ +# Send dropped `.webm` as video only when it has a video track + +## Problem + +Dragging a `.webm` file onto the desktop compose area attaches it as a plain file instead of embedding it as a video with a preview frame and duration. Every other video container the app recognises (`.mov`, `.avi`, `.mp4`, `.mpg`, `.mpeg`, `.mkv`) embeds. The same omission hides `.webm` from the "Attach → video" file picker, so the only way to send one is "Choose file", which sends it as a document. + +## Cause + +`isVideoUri` (`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt:298`) classifies attachments by file extension and does not list `.webm`; the desktop picker filter `isVideo` (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt:5`) repeats the same list with the same omission. `onFilesAttached` groups the dropped URIs by `isImage(it) || isVideoUri(it)`, so a `.webm` fails both predicates, falls into the files group and reaches `processPickedFile`, which builds a `ComposePreview.FilePreview`. + +Adding the extension to both lists is not sufficient on its own. Unlike the other containers, `.webm` is used about as often for audio alone as for video — it is `MediaRecorder`'s default audio container, and Opus/Vorbis in WebM is widespread on the web. An audio-only file classified as video reaches the video branch of `processPickedMedia`, where `getBitmapFromVideo` finds no video track, returns a null preview and raises the "video decoding" alert; the item is then skipped and nothing is attached at all (`ComposeView.kt:366-376`). That is strictly worse than the file attachment the same drop produced before. + +## Fix + +Add `.webm` to both extension lists, and for `.webm` alone decide from the file's content rather than its name. A new `expect suspend fun hasVideoTrack(uri)` (`views/helpers/Utils.kt`) reports whether the container declares a video track, reading metadata only and never decoding a frame. On desktop it is implemented with libvlc's media parse (`platform/VideoPlayer.desktop.kt`), which signals completion with an event rather than a poll, so no frame-decoding budget is needed; measured at 12-346 ms across VP8, VP9, AV1, alpha and a 42 MB file, with a 3 s timeout as a guard against a stuck parse. On Android it uses `MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO`. Either implementation answering "no", or failing, attaches the file as a file, which is always safe. + +`onFilesAttached` consults it only when a `.webm` is actually among the dropped URIs; every other attachment keeps the original synchronous code path on the caller thread, so the change adds no latency and no threading difference to images, documents or the other video containers. Files with a video track are sent as video, the rest as files. + +The content check is applied only where the user has not said how the file should be sent — drag & drop and paste. An explicitly picked video is still trusted: selecting an audio-only `.webm` through "Attach → video" raises the existing decoding error, which matches how the other containers already behave. diff --git a/scripts/android/build-android-bundle.sh b/scripts/android/build-android-bundle.sh index b784da2aad..972fb0ee72 100755 --- a/scripts/android/build-android-bundle.sh +++ b/scripts/android/build-android-bundle.sh @@ -23,5 +23,8 @@ unzip -o "$tmp/libsimplex.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/s curl -sSf "$libsup" -o "$tmp/libsupport.zip" unzip -o "$tmp/libsupport.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/src/commonMain/cpp/android/libs/arm64-v8a" -gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean build -cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/release/android-release-unsigned.apk" "$PWD/simplex-chat.apk" +# Build only the arch the libs were downloaded for +sed -i.bak 's/include(.*/include("arm64-v8a")/' "$tmp/simplex-chat/apps/multiplatform/android/build.gradle.kts" + +gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease +cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-arm64-v8a-release-unsigned.apk" "$PWD/simplex-chat.apk" diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 7edee9c304..267db9f243 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -101,7 +101,7 @@ build() { sed -i.bak 's/${extract_native_libs}/true/' "$folder/apps/multiplatform/android/src/main/AndroidManifest.xml" sed -i.bak 's/jniLibs.useLegacyPackaging =.*/jniLibs.useLegacyPackaging = true/' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak '/android {/a lint {abortOnError = false}' "$folder/apps/multiplatform/android/build.gradle.kts" - sed -i.bak '/tasks/Q' "$folder/apps/multiplatform/android/build.gradle.kts" + sed -i.bak '/^tasks {/Q' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do @@ -119,7 +119,7 @@ build() { arch_map "$arch" android_tmp_folder="${tmp}/android-${arch}" - android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/release/android-${android_arch}-release-unsigned.apk" + android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-${android_arch}-release-unsigned.apk" android_apk_output_final="simplex-chat-${android_arch}.apk" libs_folder="${folder}/apps/multiplatform/common/src/commonMain/cpp/android/libs" @@ -134,7 +134,7 @@ build() { # Build only one arch sed -i.bak "s/include(.*/include(\"${android_arch}\")/" "$folder/apps/multiplatform/android/build.gradle.kts" - gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleRelease + gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease mkdir -p "$android_tmp_folder" unzip -oqd "$android_tmp_folder" "$android_apk_output" diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7e422c51fe..4c6086dabb 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ee4dd0d8ded0f66f70a8890ce09d69e3610aa276" = "0zahz4011adavfz680wkcqvcrl2f9zjdx6dly6yqkcin4bpr6v3k"; + "https://github.com/simplex-chat/simplexmq.git"."413f30bee5a14312eca67713b5c47a3537a0c028" = "07izzz8ddcnmnk2bzcid6kf4dgln4zqkjlcsp2aqhnlgw2qh7pph"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index f8bb3224cc..4bd7262d17 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -118,7 +118,7 @@ check_apk() { verify_apk() { apk_name="$1" - # Release APKs are packaged by AGP (gradle :android:assembleRelease; AGP version is + # Release APKs are packaged by AGP (gradle :android:assembleFossRelease; AGP version is # gradle.plugin.version in apps/multiplatform/gradle.properties), which zero-pads ZIP # alignment. Do NOT add --pad-like-apksigner (standalone apksigner >= 35.0.0-rc1 uses # the 0xd935 extra-field padding) unless AGP is bumped to a packager that uses it — diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9f23bd9223..183b77d332 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.1.0.0 +version: 7.1.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index af20d97bee..6cf5453fd8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -70,6 +70,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) +import Simplex.Messaging.Server.Information (ServerPublicInfo) import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg, Worker) @@ -694,6 +695,11 @@ planResolveModeP = "never" -> pure PRMNever _ -> fail "bad PlanResolveMode" +data CommandSource + = CSLocal -- entered on this device + | CSRemoteHost RemoteHostId -- forwarded to a paired remote host + | CSRemoteCtrl -- received from a paired remote controller + allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal allowRemoteCommand = \case StartChat {} -> False @@ -781,7 +787,7 @@ data ChatResponse | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) - | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} + | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure, serverInfo :: Maybe (Either String ServerPublicInfo)} | CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, relayTestFailure :: Maybe RelayTestFailure} | CRServerOperatorConditions {conditions :: ServerOperatorConditions} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index c382a6dc8e..f5f7f581df 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -97,7 +97,7 @@ runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatR waitEither_ a1 a2 sendChatCmdStr :: ChatController -> String -> IO (Either ChatError ChatResponse) -sendChatCmdStr cc s = runReaderT (execChatCommand Nothing (encodeUtf8 $ T.pack s) 0) cc +sendChatCmdStr cc s = runReaderT (execChatCommand CSLocal (encodeUtf8 $ T.pack s) 0) cc sendChatCmd :: ChatController -> ChatCommand -> IO (Either ChatError ChatResponse) sendChatCmd cc cmd = runReaderT (execChatCommand' cmd 0) cc diff --git a/src/Simplex/Chat/Files.hs b/src/Simplex/Chat/Files.hs index 0c04b22e28..791f34c6ff 100644 --- a/src/Simplex/Chat/Files.hs +++ b/src/Simplex/Chat/Files.hs @@ -5,14 +5,20 @@ module Simplex.Chat.Files where import Simplex.Chat.Controller import Simplex.Messaging.Util (ifM) -import System.FilePath (combine, splitExtensions) +import System.FilePath (combine, makeValid, splitExtensions, takeFileName) import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getHomeDirectory, getTemporaryDirectory) +safeFileNameStr :: String -> String +safeFileNameStr = notDots . makeValid . takeFileName + where + notDots n = if n == "." || n == ".." then "_" else n + +-- | The file name is sanitized, so the combined path cannot escape the folder. uniqueCombine :: FilePath -> String -> IO FilePath uniqueCombine fPath fName = tryCombine (0 :: Int) where tryCombine n = - let (name, ext) = splitExtensions fName + let (name, ext) = splitExtensions $ safeFileNameStr fName suffix = if n == 0 then "" else "_" <> show n f = fPath `combine` (name <> suffix <> ext) in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 88cb4532ef..336474880f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -378,19 +378,24 @@ useServers as opDomains uss = xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss in (smp', xftp') -execChatCommand :: Maybe RemoteHostId -> ByteString -> Int -> CM' (Either ChatError ChatResponse) -execChatCommand rh s retryNum = +execChatCommand :: CommandSource -> ByteString -> Int -> CM' (Either ChatError ChatResponse) +execChatCommand src s retryNum = case parseChatCommand s of Left e -> pure $ chatCmdError e - Right cmd -> case rh of - Just rhId + Right cmd -> case src of + CSRemoteHost rhId | allowRemoteCommand cmd -> execRemoteCommand rhId cmd s retryNum | otherwise -> pure $ Left $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand - _ -> do - cc@ChatController {config = ChatConfig {chatHooks}} <- ask - case preCmdHook chatHooks of - Just hook -> liftIO (hook cc cmd) >>= either pure (`execChatCommand'` retryNum) - Nothing -> execChatCommand' cmd retryNum + CSRemoteCtrl + | allowRemoteCommand cmd -> execLocal cmd + | otherwise -> pure $ Left $ ChatErrorRemoteCtrl $ RCEProtocolError $ RPEInvalidBody "prohibited command" + CSLocal -> execLocal cmd + where + execLocal cmd = do + cc@ChatController {config = ChatConfig {chatHooks}} <- ask + case preCmdHook chatHooks of + Just hook -> liftIO (hook cc cmd) >>= either pure (`execChatCommand'` retryNum) + Nothing -> execChatCommand' cmd retryNum execChatCommand' :: ChatCommand -> Int -> CM' (Either ChatError ChatResponse) execChatCommand' cmd retryNum = handleCommandError $ do @@ -1675,8 +1680,9 @@ processChatCommand cxt nm = \case aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of Just Refl -> pure $ AUS SDBNew $ newUserServer srv Nothing -> throwCmdError $ "incorrect server protocol: " <> B.unpack (strEncode srv) - APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> - lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a nm (aUserId user) server) + APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> do + r <- lift $ withAgent' $ \a -> testProtocolServer a nm (aUserId user) server + pure $ uncurry (CRServerTestResult user srv) $ either ((,Nothing) . Just) (Nothing,) r TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand cxt nm $ APITestProtoServer userId srv APITestChatRelay userId address -> withUserId userId $ \user -> do @@ -3602,7 +3608,7 @@ processChatCommand cxt nm = \case ConfirmRemoteCtrl rcId -> withUser_ $ do (rc, ctrlAppInfo) <- confirmRemoteCtrl rcId pure CRRemoteCtrlConnecting {remoteCtrl_ = Just rc, ctrlAppInfo, appVersion = currentAppVersion} - VerifyRemoteCtrlSession sessId -> withUser_ $ verifyRemoteCtrlSession (execChatCommand Nothing) sessId + VerifyRemoteCtrlSession sessId -> withUser_ $ verifyRemoteCtrlSession (execChatCommand CSRemoteCtrl) sessId StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ @@ -4054,11 +4060,12 @@ processChatCommand cxt nm = \case lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> Bool -> CM ChatResponse - runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n', image = img'} domainVerified = do + runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} domainVerified = do assertUserGroupRole gInfo GROwner when (n /= n') $ checkValidName n' checkProfileImageSize img' checkGroupProfileSize p' + when (useRelays' gInfo && isJust (ma' >>= review)) $ throwCmdError "Admission review is not supported in channels" -- updateGroupProfile clears domain verification; re-set it when the caller already re-resolved the name gInfo' <- withStore $ \db -> do g <- updateGroupProfile db user gInfo p' @@ -4210,10 +4217,11 @@ processChatCommand cxt nm = \case groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> CM GroupInfo - newGroup user incognito gProfile@GroupProfile {displayName, image} useRelays memberId groupKeys_ publicMemberCount_ = do + newGroup user incognito gProfile@GroupProfile {displayName, image, memberAdmission} useRelays memberId groupKeys_ publicMemberCount_ = do checkValidName displayName checkProfileImageSize image checkGroupProfileSize gProfile + when (useRelays && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels" -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_ diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index b3f3a0fbe6..6e798f7ab2 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -48,7 +48,7 @@ import Data.Word (Word32) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery -import Simplex.Chat.Files (getChatTempDirectory) +import Simplex.Chat.Files (getChatTempDirectory, safeFileNameStr) import Simplex.Chat.Library.Internal import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved) import Simplex.Chat.Messages @@ -101,7 +101,6 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TransportError (..)) import Simplex.Messaging.Util import Simplex.Messaging.Version -import qualified System.FilePath as FP import System.Mem.Weak (Weak) import Text.Read (readMaybe) import UnliftIO.Concurrent (ThreadId, forkIO, mkWeakThreadId) @@ -916,7 +915,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = if useRelays' gInfo'' then do introduceInChannel cxt user gInfo'' m' - when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' + case mStatus of + GSMemPendingApproval -> pure () + GSMemPendingReview -> pure () + _ -> when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' else case mStatus of GSMemPendingApproval -> pure () GSMemPendingReview -> introduceToModerators cxt user gInfo'' m' @@ -1963,7 +1965,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) mkValidFileInvitation :: FileInvitation -> FileInvitation - mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = FP.makeValid $ FP.takeFileName fileName} + mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = safeFileNameStr fileName} validateFileInvitation :: FileInvitation -> CM FileInvitation validateFileInvitation fInv@FileInvitation {fileName, fileSize} @@ -3885,7 +3887,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = Just author -> action author Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" - withVerifiedMsg :: MsgEncodingI e => GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a) + withVerifiedMsg :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a) withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action = case verified of Just verifiedMsg -> Just <$> action verifiedMsg diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 9c0ed521c7..1f9ae0b9ea 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -63,7 +63,7 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) addToBatch :: Either ChatError SndMessage -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) addToBatch (Left err) acc = (Left err : addBatch acc, [], [], 0, 0) -- step over original error addToBatch (Right msg@SndMessage {msgBody, signedMsg_}) acc@(batches, bodies, msgs, len, n) - | batchLen mode len' n' <= maxLen = (batches, body : bodies, msg : msgs, len', n') + | n' <= maxBatchElementCount && batchLen mode len' n' <= maxLen = (batches, body : bodies, msg : msgs, len', n') | msgLen <= maxLen = (addBatch acc, [body], [msg], msgLen, 1) | otherwise = (errLarge msg : addBatch acc, [], [], 0, 0) where @@ -90,7 +90,7 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) | msgLen + 4 > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element - | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) + | n + 1 <= maxBatchElementCount && len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) -- doesn't fit: stop adding further messages | otherwise = (msgBodies, accepted, large, len, n) where @@ -112,7 +112,7 @@ batchElements maxLen = finish . foldl' addToBatch ([], [], 0, 0, 0) where addToBatch (batches, elems, len, n, dropped) el | elLen + 4 > maxLen = (batches, elems, len, n, dropped + 1) - | len + elLen + (n + 1) * 2 + 2 <= maxLen = (batches, el : elems, len + elLen, n + 1, dropped) + | n + 1 <= maxBatchElementCount && len + elLen + (n + 1) * 2 + 2 <= maxLen = (batches, el : elems, len + elLen, n + 1, dropped) | otherwise = (closeBatch elems : batches, [el], elLen, 1, dropped) where elLen = B.length el @@ -182,7 +182,7 @@ batchProfilesWithBody maxLen body labeled = initState = (initLen, initCount, [], [], []) step (totalLen, count, acceptedPairs, overflow, large) (s, e) | B.length e + 4 > maxLen = (totalLen, count, acceptedPairs, overflow, s : large) - | count >= 255 = full + | count >= maxBatchElementCount = full | candidateLen <= maxLen = (candidateLen, count + 1, (s, e) : acceptedPairs, overflow, large) | otherwise = full where @@ -215,7 +215,7 @@ batchProfiles maxLen = addToBatch (s, e) acc@(batches, elems, members, len, n, large) | B.length e + 4 > maxLen = (batches, elems, members, len, n, s : large) -- batch overhead: '=' + count (2) + 2-byte length prefix per element - | n + 1 <= 255 && len + B.length e + (n + 1) * 2 + 2 <= maxLen = + | n + 1 <= maxBatchElementCount && len + B.length e + (n + 1) * 2 + 2 <= maxLen = (batches, e : elems, s : members, len + B.length e, n + 1, large) -- doesn't fit current — flush and start new with this element alone | otherwise = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index fbf2a226e1..09af069ef7 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -352,7 +352,7 @@ chatSendCmd cc cmd = chatSendRemoteCmdRetry cc Nothing cmd 0 {-# INLINE chatSendCmd #-} chatSendRemoteCmdRetry :: ChatController -> Maybe RemoteHostId -> B.ByteString -> Int -> IO JSONByteString -chatSendRemoteCmdRetry cc rh s retryNum = J.encode . eitherToResult rh <$> runReaderT (execChatCommand rh s retryNum) cc +chatSendRemoteCmdRetry cc rh s retryNum = J.encode . eitherToResult rh <$> runReaderT (execChatCommand (maybe CSLocal CSRemoteHost rh) s retryNum) cc chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = J.encode . uncurry eitherToResult <$> readChatResponse diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 8d9c8ecfcc..15550d34e4 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} @@ -909,6 +910,10 @@ maxCompressedMsgLength = 13380 maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 +-- Applies to all batch formats; 255 is the maximum for the 1-byte count in the binary batch format. +maxBatchElementCount :: Int +maxBatchElementCount = 255 + -- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the -- promotion cap over the promoted (member/moderator/admin) set. maxGroupRosterSize :: Int @@ -953,10 +958,16 @@ encodeChatMessage maxSize msg = do parseChatMessages :: ByteString -> [Either String AParsedMsg] parseChatMessages "" = [Left "empty string"] -parseChatMessages msg = case B.head msg of +parseChatMessages msg = checkBatchLimit $ case B.head msg of 'X' -> decodeCompressed (B.tail msg) c -> parseUncompressed c msg where + checkBatchLimit ms + | ms `lengthLE` maxBatchElementCount = ms + | otherwise = [Left "too many messages in batch"] + lengthLE :: [a] -> Int -> Bool + [] `lengthLE` !n = n >= 0 + (_ : xs) `lengthLE` !n = n > 0 && xs `lengthLE` (n - 1) parseUncompressed c s = case c of '[' -> case J.eitherDecodeStrict' s of Right v -> map (fmap plainMsg . parseItem) v diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 39405bd1ba..991218367a 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -65,10 +65,10 @@ import Simplex.Messaging.Util import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..), RCVerifiedInvitation (..), verifySignedInvitation) import Simplex.RemoteControl.Types -import System.FilePath (takeFileName, ()) +import System.FilePath (takeDirectory, takeFileName, ()) import UnliftIO import UnliftIO.Concurrent (forkIO) -import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile) +import UnliftIO.Directory (canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile) remoteFilesFolder :: String remoteFilesFolder = "simplex_v1_files" @@ -553,7 +553,7 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do logDebug $ "Send: " <> tshow command - -- execCC checks for remote-allowed commands + -- execCC is execChatCommand CSRemoteCtrl, which checks allowRemoteCommand -- convert errors thrown in execCC into error responses to prevent aborting the protocol wrapper RRChatResponse . eitherToResult <$> execCC (encodeUtf8 command) retryNum @@ -574,10 +574,19 @@ handleStoreFile rfKN fileName fileSize fileDigest getChunk = Nothing -> storeFileTo =<< getDefaultFilesFolder storeFileTo :: FilePath -> CM' (Either RemoteProtocolError FilePath) storeFileTo dir = liftIO . tryAllErrors' $ do + unless (validRemoteFileName fileName) $ throwError $ RPEInvalidBody "invalid file name" filePath <- liftIO $ dir `uniqueCombine` fileName + -- resolves symlinks, so it also catches a final component linking outside the folder + canonPath <- liftIO $ canonicalizePath filePath + inDir <- liftIO $ (takeDirectory canonPath ==) <$> canonicalizePath dir + unless inDir $ throwError $ RPEInvalidBody "file path outside of files folder" receiveEncryptedFile rfKN getChunk fileSize fileDigest filePath pure filePath +-- The controller only ever sends a bare file name (see storeRemoteFile), so a path is a protocol violation. +validRemoteFileName :: FilePath -> Bool +validRemoteFileName fName = fName == takeFileName fName && fName `notElem` (["", ".", ".."] :: [FilePath]) + handleGetFile :: User -> RemoteFile -> Respond -> CM () handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do logDebug $ "GetFile: " <> tshow filePath diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index e0ee10aff9..746b1e137f 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -62,7 +62,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do cmd = parseChatCommand bs rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s - r <- execChatCommand rh' bs 0 `runReaderT` cc + r <- execChatCommand (maybe CSLocal CSRemoteHost rh') bs 0 `runReaderT` cc case r of Right r' -> processResp cmd rh r' Left _ -> when (isMessage cmd) $ echo s diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 03f644e641..63a3d5cc70 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -167,7 +167,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s getRemoteUser rhId = - runReaderT (execChatCommand (Just rhId) "/user" 0) cc >>= \case + runReaderT (execChatCommand (CSRemoteHost rhId) "/user" 0) cc >>= \case Right CRActiveUser {user} -> updateRemoteUser ct user rhId cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5894d12fd3..72631dc3f8 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -126,7 +126,11 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] CRChatContentTypes cts -> [plain $ "Chat content types: " <> T.intercalate ", " (map (safeDecodeUtf8 . strEncode) cts)] CRChatTags u tags -> ttyUser u [viewJSON tags] - CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure + CRServerTestResult u srv testFailure info -> ttyUser u $ viewServerTestResult srv testFailure <> maybe [] viewServerInfo info + where + viewServerInfo = \case + Left e -> [plain $ "Server Info Error: " <> T.pack e] + Right i -> [plain $ "Server Info: " <> tshow i] CRChatRelayTestResult u relayProfile_ relayTestFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ relayTestFailure_ CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 00cbbd757b..01515fb6a3 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -33,6 +33,7 @@ import Simplex.Chat.Protocol GrpMsgForward (GrpMsgForward), MsgContent (MCText), VerifiedMsg (VMUnsigned), + maxBatchElementCount, maxEncodedMsgLength, mcSimple, ) @@ -45,6 +46,7 @@ batchingTests = describe "message batching tests" $ do testBatchingCorrectness testBinaryBatchingCorrectness it "image x.msg.new and x.msg.file.descr should fit into single batch" testImageFitsSingleBatch + it "splits a batch that exceeds the element count limit" testBatchElementCountLimit it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow @@ -150,6 +152,13 @@ testImageFitsSingleBatch = do runBatcherTest' BMJson maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] +-- elements are far below maxEncodedMsgLength, so only the element count guard can split this +testBatchElementCountLimit :: IO () +testBatchElementCountLimit = + runBatcherTest' BMJson maxEncodedMsgLength (replicate (maxBatchElementCount + 1) "a") [] ["a", batched] + where + batched = "[" <> B.intercalate "," (replicate maxBatchElementCount "a") <> "]" + testRelayBatchAllLarge :: IO () testRelayBatchAllLarge = do let task1 = deliveryTask 1 "one" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 63dbea549f..e241ddf15b 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -9,6 +9,9 @@ module ProtocolTests where import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.List (isInfixOf) +import qualified Data.List.NonEmpty as L import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Simplex.Chat.Library.Internal (decodeLinkUserData, encodeShortLinkData) import Simplex.Chat.Protocol @@ -16,8 +19,10 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Compression (compress1) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet +import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Protocol (EntityId (..), supportedSMPClientVRange) import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Version @@ -27,6 +32,22 @@ protocolTests :: Spec protocolTests = do decodeChatMessageTest shortLinkDataTests + batchLimitTests + +batchLimitTests :: Spec +batchLimitTests = describe "Chat message batch limits" $ do + it "parses a JSON batch at the element count limit" $ + length (parseChatMessages $ jsonBatch maxBatchElementCount) `shouldBe` maxBatchElementCount + it "rejects a JSON batch above the element count limit" $ + batchError (jsonBatch $ maxBatchElementCount + 1) `shouldSatisfy` isInfixOf "too many messages in batch" + it "rejects compressed blocks that together exceed the element count limit" $ + batchError (compressedBatch 2 maxBatchElementCount) `shouldSatisfy` isInfixOf "too many messages in batch" + where + jsonBatch n = "[" <> B.intercalate "," (replicate n "{}") <> "]" + compressedBatch k n = markCompressedBatch . smpEncode . L.fromList $ replicate k (compress1 $ jsonBatch n) + batchError s = case parseChatMessages s of + [Left e] -> e + rs -> "expected a single error, got " <> show (length rs) <> " results" srv :: SMPServer srv = SMPServer "smp.simplex.im" "5223" (C.KeyHash "\215m\248\251") diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index e96d531805..1bb6ac34eb 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -11,22 +11,25 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Logger.Simple import Control.Monad +import Control.Monad.Except (runExceptT) import qualified Data.Aeson as J import qualified Data.ByteString as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.List (find, isPrefixOf) import qualified Data.Map.Strict as M import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), versionNumber) +import Simplex.Chat.Files (safeFileNameStr) import Simplex.Chat.Library.Commands (parseChatCommand) import qualified Simplex.Chat.Controller as Controller import Simplex.Chat.Mobile.File -import Simplex.Chat.Remote (remoteFilesFolder) +import Simplex.Chat.Remote (remoteFilesFolder, validRemoteFileName) +import Simplex.Chat.Remote.Protocol (remoteStoreFile) import Simplex.Chat.Remote.Types import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Util import Simplex.RemoteControl.Types (RCCtrlAddress (..)) -import System.FilePath (()) +import System.FilePath (takeFileName, ()) import Test.Hspec hiding (it) import UnliftIO import UnliftIO.Concurrent @@ -40,10 +43,26 @@ remoteTests = describe "Remote" $ do `shouldSatisfy` \case Right (StartRemoteHost Nothing (Just (RCCtrlAddress _ "Ethernet 2")) (Just 12345)) -> True _ -> False + describe "stored file name" $ do + it "rejects names with directory components" $ \_ -> + filter validRemoteFileName ["../x", "../../etc/passwd", "/etc/cron.d/x", "a/b", "x/", "", ".", ".."] + `shouldBe` [] + it "accepts bare file names" $ \_ -> + filter (not . validRemoteFileName) ["test.pdf", "test_1.pdf", ".hidden", "a b.tar.gz"] + `shouldBe` [] + it "sanitizes any name to a real file name" $ \_ -> + filter (not . sanitized) fileNames `shouldBe` [] + it "sanitizes to a name with no directory components" $ \_ -> + filter (not . bareName) fileNames `shouldBe` [] xdescribe "No compression" $ aroundWith (. ((False, False),)) runRemoteTests xdescribe "Mobile offers compression" $ aroundWith (. ((True, False),)) runRemoteTests xdescribe "Desktop offers compression" $ aroundWith (. ((False, True),)) runRemoteTests describe "With compression" $ aroundWith (. ((True, True),)) runRemoteTests + where + fileNames :: [FilePath] + fileNames = ["", ".", "..", "...", "../x", "../../etc/passwd", "/etc/cron.d/x", "a/b", "x/", "test.pdf", ".hidden", "a b.tar.gz"] + sanitized n = let n' = safeFileNameStr n in n' /= "" && n' /= "." && n' /= ".." + bareName n = let n' = safeFileNameStr n in n' == takeFileName n' runRemoteTests :: SpecWith ((Bool, Bool), TestParams) runRemoteTests = do @@ -243,8 +262,9 @@ remoteStoreFileTest = contactBob desktop bob rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop) - desktopHostStore <- case M.lookup (RHId 1) rhs of - Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles storePath remoteFilesFolder + (rhClient, desktopHostStore) <- case M.lookup (RHId 1) rhs of + Just (_, RHSessionConnected {rhClient, storePath}) -> + pure (rhClient, desktopHostFiles storePath remoteFilesFolder) _ -> fail "Host session 1 should be started" desktop ##> "/store remote file 1 tests/fixtures/test.pdf" desktop <## "file test.pdf stored on remote host 1" @@ -261,6 +281,17 @@ remoteStoreFileTest = chatReadFile (mobileFiles "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) chatReadFile (desktopHostStore "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src) + -- the host rejects a traversal name before draining the attachment; only calling the protocol + -- directly can put such a name on the wire, as /store remote file sanitizes it controller-side + runExceptT (remoteStoreFile rhClient "tests/fixtures/test.pdf" "../x") >>= \case + Left (RPEInvalidBody _) -> pure () + r -> fail $ "expected RPEInvalidBody, got " <> show r + doesFileExist "./tests/tmp/x" `shouldReturn` False + -- the undrained attachment did not break the session + desktop ##> "/store remote file 1 tests/fixtures/test.pdf" + desktop <## "file test_3.pdf stored on remote host 1" + B.readFile (mobileFiles "test_3.pdf") `shouldReturn` src + removeFile (desktopHostStore "test_1.pdf") removeFile (desktopHostStore "test_2.pdf") diff --git a/website/langs/en.json b/website/langs/en.json index 3cbcb12ee2..8841f060e0 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -264,6 +264,8 @@ "index-hero-h1": "Be
Free", "index-hero-h2": "In Your Network", "index-hero-p1": "The first network without user IDs.
You own your contacts, groups and channels.", + "index-hero-invest": "Invest in SimpleX Chat.", + "index-hero-invest-cta": "Learn more on Wefunder.", "index-hero-download-desktop-btn-title": "Download SimpleX Desktop App", "index-testflight-title": "SimpleX iOS beta-release on TestFlight", "index-f-droid-title": "SimpleX app via F-Droid", @@ -370,7 +372,7 @@ "file-proto-h-4": "Independent data routers", "file-proto-p-4": "When file is split to fragments, it is sent via network routers operated by independent parties. No operator can see the actual file size or name. Even if a router is compromised, it can only see encrypted fragments of fixed size. File fragments are cached by network routers for approximately 48 hours.", "file-proto-spec": "Read the XFTP protocol specification →", - "links": "Links", + "links": "Community", "links-title": "Community Links", "links-all-languages": "All languages" } diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index cec2aa0a01..ebff551a5c 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -110,11 +110,11 @@
- +