diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index e7a597804..ec4cb9009 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -440,9 +440,9 @@ struct ChatInfoView: View { do { try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) await MainActor.run { - chatModel.removeChat(chat.chatInfo.id) - chatModel.chatId = nil dismiss() + chatModel.chatId = nil + chatModel.removeChat(chat.chatInfo.id) } } catch let error { logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 1c32f36c9..359633a5f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -84,7 +84,7 @@ struct CIFileView: View { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") if let user = ChatModel.shared.currentUser { - let encrypted = file.fileProtocol == .xftp && privacyEncryptLocalFilesGroupDefault.get() + let encrypted = privacyEncryptLocalFilesGroupDefault.get() await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 75e81790a..3b9ef347e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -299,9 +299,9 @@ struct GroupChatInfoView: View { do { try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) await MainActor.run { - chatModel.removeChat(chat.chatInfo.id) - chatModel.chatId = nil dismiss() + chatModel.chatId = nil + chatModel.removeChat(chat.chatInfo.id) } } catch let error { logger.error("deleteGroupAlert apiDeleteChat error: \(error.localizedDescription)") diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift index 554daaebb..8e8885b51 100644 --- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift @@ -53,7 +53,7 @@ struct AdvancedNetworkSettings: View { timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [5_000000, 7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [3_000000, 5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) - timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [10_000, 20_000, 40_000, 75_000, 100_000], label: secondsLabel) + timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 60_000, 90_000, 120_000], label: secondsLabel) timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel) intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "") Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index f0a5c2a06..645fdb595 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -383,7 +383,7 @@ func apiSetFileToReceive(fileId: Int64, encrypted: Bool) { func autoReceiveFile(_ file: CIFile, encrypted: Bool) -> ChatItem? { switch file.fileProtocol { case .smp: - return apiReceiveFile(fileId: file.fileId, encrypted: false)?.chatItem + return apiReceiveFile(fileId: file.fileId, encrypted: encrypted)?.chatItem case .xftp: apiSetFileToReceive(fileId: file.fileId, encrypted: encrypted) return nil diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 161dbaa8e..d3e36383a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -48,11 +48,11 @@ 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; - 5C5624FC2ABB39B900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5624F72ABB39B900A21210 /* libgmpxx.a */; }; - 5C5624FD2ABB39B900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5624F82ABB39B900A21210 /* libgmp.a */; }; - 5C5624FE2ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5624F92ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY-ghc8.10.7.a */; }; - 5C5624FF2ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5624FA2ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY.a */; }; - 5C5625002ABB39B900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5624FB2ABB39B900A21210 /* libffi.a */; }; + 5C5625062ABCBD3200A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625012ABCBD3200A21210 /* libffi.a */; }; + 5C5625072ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625022ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo-ghc8.10.7.a */; }; + 5C5625082ABCBD3200A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625032ABCBD3200A21210 /* libgmp.a */; }; + 5C5625092ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625042ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo.a */; }; + 5C56250A2ABCBD3200A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C5625052ABCBD3200A21210 /* libgmpxx.a */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; }; 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; }; @@ -293,11 +293,11 @@ 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = ""; }; 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = ""; }; - 5C5624F72ABB39B900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C5624F82ABB39B900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C5624F92ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY-ghc8.10.7.a"; sourceTree = ""; }; - 5C5624FA2ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY.a"; sourceTree = ""; }; - 5C5624FB2ABB39B900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C5625012ABCBD3200A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C5625022ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo-ghc8.10.7.a"; sourceTree = ""; }; + 5C5625032ABCBD3200A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C5625042ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo.a"; sourceTree = ""; }; + 5C5625052ABCBD3200A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = ""; }; 5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; @@ -507,13 +507,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C5624FE2ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY-ghc8.10.7.a in Frameworks */, - 5C5624FF2ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY.a in Frameworks */, - 5C5624FC2ABB39B900A21210 /* libgmpxx.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C5625002ABB39B900A21210 /* libffi.a in Frameworks */, - 5C5624FD2ABB39B900A21210 /* libgmp.a in Frameworks */, + 5C5625082ABCBD3200A21210 /* libgmp.a in Frameworks */, + 5C5625062ABCBD3200A21210 /* libffi.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5C5625092ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo.a in Frameworks */, + 5C5625072ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo-ghc8.10.7.a in Frameworks */, + 5C56250A2ABCBD3200A21210 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +574,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C5624FB2ABB39B900A21210 /* libffi.a */, - 5C5624F82ABB39B900A21210 /* libgmp.a */, - 5C5624F72ABB39B900A21210 /* libgmpxx.a */, - 5C5624F92ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY-ghc8.10.7.a */, - 5C5624FA2ABB39B900A21210 /* libHSsimplex-chat-5.3.0.8-D1oMkI9pySuA3Aa2cfRrBY.a */, + 5C5625012ABCBD3200A21210 /* libffi.a */, + 5C5625032ABCBD3200A21210 /* libgmp.a */, + 5C5625052ABCBD3200A21210 /* libgmpxx.a */, + 5C5625022ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo-ghc8.10.7.a */, + 5C5625042ABCBD3200A21210 /* libHSsimplex-chat-5.3.0.9-JpoF1vnleecHyL9iiCdgEo.a */, ); path = Libraries; sourceTree = ""; @@ -1486,7 +1486,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 171; + CURRENT_PROJECT_VERSION = 172; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1528,7 +1528,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 171; + CURRENT_PROJECT_VERSION = 172; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1608,7 +1608,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 171; + CURRENT_PROJECT_VERSION = 172; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1640,7 +1640,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 171; + CURRENT_PROJECT_VERSION = 172; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1672,7 +1672,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 171; + CURRENT_PROJECT_VERSION = 172; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1718,7 +1718,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 171; + CURRENT_PROJECT_VERSION = 172; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index e67a24538..b0834f571 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -1092,7 +1092,7 @@ public struct NetCfg: Codable, Equatable { sessionMode: TransportSessionMode.user, tcpConnectTimeout: 15_000_000, tcpTimeout: 10_000_000, - tcpTimeoutPerKb: 20_000, + tcpTimeoutPerKb: 30_000, tcpKeepAlive: KeepAliveOpts.defaults, smpPingInterval: 1200_000_000, smpPingCount: 3, @@ -1104,7 +1104,7 @@ public struct NetCfg: Codable, Equatable { sessionMode: TransportSessionMode.user, tcpConnectTimeout: 30_000_000, tcpTimeout: 20_000_000, - tcpTimeoutPerKb: 40_000, + tcpTimeoutPerKb: 60_000, tcpKeepAlive: KeepAliveOpts.defaults, smpPingInterval: 1200_000_000, smpPingCount: 3, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a24c82110..c0ec04857 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2144,7 +2144,6 @@ public struct ChatItem: Identifiable, Decodable { } public var encryptLocalFile: Bool { - file?.fileProtocol == .xftp && content.msgContent?.isVideo == false && privacyEncryptLocalFilesGroupDefault.get() } diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index bd45ee125..67a8fea87 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -139,8 +139,6 @@ dependencies { //implementation("androidx.compose.material:material-icons-extended:$compose_version") //implementation("androidx.compose.ui:ui-util:$compose_version") - implementation("com.google.accompanist:accompanist-pager:0.25.1") - testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 06def4ce1..512e9efc1 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -73,7 +73,7 @@ class MainActivity: FragmentActivity() { override fun onStop() { super.onStop() - VideoPlayer.stopAll() + VideoPlayerHolder.stopAll() AppLock.appWasHidden() } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 5b9560b07..6a5fd1d0f 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -97,6 +97,7 @@ kotlin { implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") + implementation("uk.co.caprica:vlcj:4.7.0") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index 5996193ab..8df99d15f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -27,7 +27,7 @@ actual class RecorderNative: RecorderInterface { } override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { - VideoPlayer.stopAll() + VideoPlayerHolder.stopAll() AudioPlayer.stop() val rec: MediaRecorder recorder = initRecorder().also { rec = it } @@ -140,7 +140,7 @@ actual object AudioPlayer: AudioPlayerInterface { return null } - VideoPlayer.stopAll() + VideoPlayerHolder.stopAll() RecorderInterface.stopRecording?.invoke() val current = currentlyPlaying.value if (current == null || current.first != fileSource.filePath) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt index 984f83d45..9d5eadad7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt @@ -1,10 +1,13 @@ package chat.simplex.common.platform +import android.media.MediaMetadataRetriever import android.media.session.PlaybackState import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import chat.simplex.common.helpers.toUri import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import com.google.android.exoplayer2.* @@ -17,49 +20,15 @@ import kotlinx.coroutines.* import java.io.File import java.net.URI -actual class VideoPlayer private constructor( - private val uri: URI, - private val gallery: Boolean, +actual class VideoPlayer actual constructor( + override val uri: URI, + override val gallery: Boolean, private val defaultPreview: ImageBitmap, defaultDuration: Long, soundEnabled: Boolean ): VideoPlayerInterface { - actual companion object { - private val players: MutableMap, VideoPlayer> = mutableMapOf() - private val previewsAndDurations: MutableMap = mutableMapOf() - - actual fun getOrCreate( - uri: URI, - gallery: Boolean, - defaultPreview: ImageBitmap, - defaultDuration: Long, - soundEnabled: Boolean - ): VideoPlayer = - players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) } - - actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean = - player(fileName, gallery)?.enableSound(enable) == true - - private fun player(fileName: String?, gallery: Boolean): VideoPlayer? { - fileName ?: return null - return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery } - } - - actual fun release(uri: URI, gallery: Boolean, remove: Boolean) = - player(uri.path, gallery)?.release(remove).run { } - - actual fun stopAll() { - players.values.forEach { it.stop() } - } - - actual fun releaseAll() { - players.values.forEach { it.release(false) } - players.clear() - previewsAndDurations.clear() - } - } - private val currentVolume: Float + override val soundEnabled: MutableState = mutableStateOf(soundEnabled) override val brokenVideo: MutableState = mutableStateOf(false) override val videoPlaying: MutableState = mutableStateOf(false) @@ -114,7 +83,7 @@ actual class VideoPlayer private constructor( RecorderInterface.stopRecording?.invoke() } AudioPlayer.stop() - stopAll() + VideoPlayerHolder.stopAll() if (listener.value == null) { runCatching { val dataSourceFactory = DefaultDataSource.Factory(androidAppContext, DefaultHttpDataSource.Factory()) @@ -224,14 +193,14 @@ actual class VideoPlayer private constructor( override fun release(remove: Boolean) { player.release() if (remove) { - players.remove(uri to gallery) + VideoPlayerHolder.players.remove(uri to gallery) } } private fun setPreviewAndDuration() { // It freezes main thread, doing it in IO thread CoroutineScope(Dispatchers.IO).launch { - val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) } + val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) } withContext(Dispatchers.Main) { preview.value = previewAndDuration.preview ?: defaultPreview duration.value = (previewAndDuration.duration ?: 0) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index ade538a04..d4efdc3e5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -51,7 +51,7 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: } @Composable -actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) { +actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) { AndroidView( factory = { ctx -> StyledPlayerView(ctx).apply { 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 e3c857716..127f13cd5 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 @@ -309,7 +309,7 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) } } -actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { +actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { val mmr = MediaMetadataRetriever() mmr.setDataSource(androidAppContext, uri.toUri()) val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 33b80322a..887abe756 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -7,13 +7,12 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration import chat.simplex.common.model.* +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.common.platform.AudioPlayer -import chat.simplex.common.platform.chatController import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -1417,8 +1416,7 @@ data class ChatItem ( val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null val encryptLocalFile: Boolean - get() = file?.fileProtocol == FileProtocol.XFTP && - content.msgContent !is MsgContent.MCVideo && + get() = content.msgContent !is MsgContent.MCVideo && chatController.appPrefs.privacyEncryptLocalFiles.get() val memberDisplayName: String? get() = @@ -2113,6 +2111,23 @@ data class CryptoFile( val isAbsolutePath: Boolean get() = File(filePath).isAbsolute + @Transient + private var tmpFile: File? = null + + fun createTmpFileIfNeeded(): File { + if (tmpFile == null) { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + ChatModel.filesToDelete.add(tmpFile) + this.tmpFile = tmpFile + } + return tmpFile!! + } + + fun deleteTmpFile() { + tmpFile?.delete() + } + companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4fba9b7cb..060738bc1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2392,7 +2392,7 @@ data class NetCfg( sessionMode = TransportSessionMode.User, tcpConnectTimeout = 15_000_000, tcpTimeout = 10_000_000, - tcpTimeoutPerKb = 20_000, + tcpTimeoutPerKb = 30_000, tcpKeepAlive = KeepAliveOpts.defaults, smpPingInterval = 1200_000_000, smpPingCount = 3 @@ -2406,7 +2406,7 @@ data class NetCfg( sessionMode = TransportSessionMode.User, tcpConnectTimeout = 30_000_000, tcpTimeout = 20_000_000, - tcpTimeoutPerKb = 40_000, + tcpTimeoutPerKb = 60_000, tcpKeepAlive = KeepAliveOpts.defaults, smpPingInterval = 1200_000_000, smpPingCount = 3 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt index bde9d8a49..5c3b50bbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt @@ -7,6 +7,8 @@ import java.net.URI interface VideoPlayerInterface { data class PreviewAndDuration(val preview: ImageBitmap?, val duration: Long?, val timestamp: Long) + val uri: URI + val gallery: Boolean val soundEnabled: MutableState val brokenVideo: MutableState val videoPlaying: MutableState @@ -20,18 +22,45 @@ interface VideoPlayerInterface { fun release(remove: Boolean) } -expect class VideoPlayer: VideoPlayerInterface { - companion object { - fun getOrCreate( - uri: URI, - gallery: Boolean, - defaultPreview: ImageBitmap, - defaultDuration: Long, - soundEnabled: Boolean - ): VideoPlayer - fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean - fun release(uri: URI, gallery: Boolean, remove: Boolean) - fun stopAll() - fun releaseAll() +expect class VideoPlayer( + uri: URI, + gallery: Boolean, + defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean +): VideoPlayerInterface + +object VideoPlayerHolder { + val players: MutableMap, VideoPlayer> = mutableMapOf() + val previewsAndDurations: MutableMap = mutableMapOf() + + fun getOrCreate( + uri: URI, + gallery: Boolean, + defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean + ): VideoPlayer = + players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) } + + fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean = + player(fileName, gallery)?.enableSound(enable) == true + + private fun player(fileName: String?, gallery: Boolean): VideoPlayer? { + fileName ?: return null + return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery } + } + + fun release(uri: URI, gallery: Boolean, remove: Boolean) = + player(uri.path, gallery)?.release(remove).run { } + + fun stopAll() { + players.values.forEach { it.stop() } + } + + fun releaseAll() { + players.values.forEach { it.release(false) } + players.clear() + previewsAndDurations.clear() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 31f6fee76..e13439e18 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -449,7 +449,7 @@ fun ChatLayout( val images = groups[true] ?: emptyList() val files = groups[false] ?: emptyList() if (images.isNotEmpty()) { - composeState.processPickedMedia(images, null) + CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(images, null) } } else if (files.isNotEmpty()) { composeState.processPickedFile(uris.first(), null) } @@ -459,7 +459,7 @@ fun ChatLayout( tmpFile.deleteOnExit() chatModel.filesToDelete.add(tmpFile) val uri = tmpFile.toURI() - composeState.processPickedMedia(listOf(uri), null) + CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(uri), null) } }, onText = { // Need to parse HTML in order to correctly display the content @@ -737,7 +737,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } DisposableEffectOnGone( whenGone = { - VideoPlayer.releaseAll() + VideoPlayerHolder.releaseAll() } ) LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { 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 f26ce0a7a..c6e6ca7b7 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 @@ -176,7 +176,7 @@ fun MutableState.processPickedFile(uri: URI?, text: String?) { } } -fun MutableState.processPickedMedia(uris: List, text: String?) { +suspend fun MutableState.processPickedMedia(uris: List, text: String?) { val content = ArrayList() val imagesPreview = ArrayList() uris.forEach { uri -> @@ -237,7 +237,7 @@ fun ComposeView( val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } - AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile, composeState::processPickedMedia) + AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 8de805ba5..87f4aa4f3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -71,7 +71,7 @@ fun CIFileView( when (file.fileStatus) { is CIFileStatus.RcvInvitation -> { if (fileSizeValid()) { - val encrypted = file.fileProtocol == FileProtocol.XFTP && chatController.appPrefs.privacyEncryptLocalFiles.get() + val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get() receiveFile(file.fileId, encrypted) } else { AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index aad1e8a8f..78bdf53d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -50,7 +50,7 @@ fun CIVideoView( }) } else { Box { - ImageView(preview, showMenu, onClick = { + VideoPreviewImageView(preview, onClick = { if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation -> @@ -75,7 +75,10 @@ fun CIVideoView( else -> {} } } - }) + }, + onLongClick = { + showMenu.value = true + }) if (file != null) { DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } @@ -90,7 +93,7 @@ fun CIVideoView( @Composable private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState, onClick: () -> Unit) { - val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } + val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } val videoPlaying = remember(uri.path) { player.videoPlaying } val progress = remember(uri.path) { player.progress } val duration = remember(uri.path) { player.duration } @@ -111,6 +114,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau stop() } } + val onLongClick = { showMenu.value = true } Box { val windowWidth = LocalWindowWidth() val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } @@ -118,12 +122,12 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau player, width, onClick = onClick, - onLongClick = { showMenu.value = true }, + onLongClick = onLongClick, stop ) if (showPreview.value) { - ImageView(preview, showMenu, onClick) - PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play) + VideoPreviewImageView(preview, onClick, onLongClick) + PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick) } DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) } @@ -201,7 +205,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState, durat } @Composable -private fun ImageView(preview: ImageBitmap, showMenu: MutableState, onClick: () -> Unit) { +fun VideoPreviewImageView(preview: ImageBitmap, onClick: () -> Unit, onLongClick: () -> Unit) { val windowWidth = LocalWindowWidth() val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } Image( @@ -210,10 +214,10 @@ private fun ImageView(preview: ImageBitmap, showMenu: MutableState, onC modifier = Modifier .width(width) .combinedClickable( - onLongClick = { showMenu.value = true }, + onLongClick = onLongClick, onClick = onClick ) - .onRightClick { showMenu.value = true }, + .onRightClick(onLongClick), contentScale = ContentScale.FillWidth, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 9664cabc4..05d11208f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -46,9 +46,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> val scope = rememberCoroutineScope() val playersToRelease = rememberSaveable { mutableSetOf() } DisposableEffectOnGone( - whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } } + whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } } ) - HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> + + @Composable + fun Content(index: Int) { Column( Modifier .fillMaxSize() @@ -127,7 +129,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> FullScreenImageView(modifier, data, imageBitmap) } else if (media is ProviderMedia.Video) { val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } - VideoView(modifier, media.uri, preview, index == settledCurrentPage) + VideoView(modifier, media.uri, preview, index == settledCurrentPage, close) DisposableEffect(Unit) { onDispose { playersToRelease.add(media.uri) } } @@ -135,14 +137,19 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } } } + if (appPlatform.isAndroid) { + HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) } + } else { + Content(pagerState.currentPage) + } } @Composable expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) @Composable -private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) { - val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) } +private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean, close: () -> Unit) { + val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, 0L, true) } val isCurrentPage = rememberUpdatedState(currentPage) val play = { player.play(true) @@ -154,13 +161,16 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, player.enableSound(true) snapshotFlow { isCurrentPage.value } .distinctUntilChanged() - .collect { if (it) play() else stop() } + .collect { + // Do not autoplay on desktop because it needs workaround + if (it && appPlatform.isAndroid) play() else if (!it) stop() + } } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - FullScreenVideoView(player, modifier) + FullScreenVideoView(player, modifier, close) } } @Composable -expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) +expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) 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 d8a14cb51..6d7450a21 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 @@ -66,6 +66,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf if (chatModel.chatId.value != null) { ModalManager.end.closeModalsExceptFirst() } + AudioPlayer.stop() + VideoPlayerHolder.stopAll() } } val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp 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 6aaf7a9fd..2aad0bc3d 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 @@ -267,7 +267,7 @@ fun getMaxFileSize(fileProtocol: FileProtocol): Long { } } -expect fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true): VideoPlayerInterface.PreviewAndDuration +expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true): VideoPlayerInterface.PreviewAndDuration fun Color.darker(factor: Float = 0.1f): Color = Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index ce09ee661..eedf604a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -164,9 +164,10 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { ) } SectionItemView { + // can't be higher than 130ms to avoid overflow on 32bit systems TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(10_000, 20_000, 40_000, 75_000, 100_000), secondsLabel + listOf(15_000, 30_000, 60_000, 90_000, 120_000), secondsLabel ) } SectionItemView { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 612217925..7193fbe2b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -29,6 +29,10 @@ fun initApp() { //testCrypto() } +fun discoverVlcLibs(path: String) { + uk.co.caprica.vlcj.binding.LibC.INSTANCE.setenv("VLC_PLUGIN_PATH", path, 1) +} + private fun applyAppLocale() { val lang = ChatController.appPrefs.appLanguage.get() if (lang == null || lang == Locale.getDefault().language) return diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 9042a6283..46124a44f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +val vlcDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex-vlc").also { it.deleteOnExit() } + actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 8e6a7d7ef..ed8efcd57 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -1,9 +1,17 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import chat.simplex.common.model.* -import chat.simplex.common.views.usersettings.showInDevelopingAlert -import kotlinx.coroutines.CoroutineScope +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import kotlinx.coroutines.* +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.base.State +import uk.co.caprica.vlcj.player.component.AudioPlayerComponent +import java.io.File +import kotlin.math.max actual class RecorderNative: RecorderInterface { override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { @@ -18,36 +26,187 @@ actual class RecorderNative: RecorderInterface { } actual object AudioPlayer: AudioPlayerInterface { - override fun play(fileSource: CryptoFile, audioPlaying: MutableState, progress: MutableState, duration: MutableState, resetOnEnd: Boolean) { - showInDevelopingAlert() + val player by lazy { AudioPlayerComponent().mediaPlayer() } + + // Filepath: String, onProgressUpdate + private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) + private var progressJob: Job? = null + + enum class TrackState { + PLAYING, PAUSED, REPLACED + } + + // Returns real duration of the track + private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { + val absoluteFilePath = getAppFilePath(fileSource.filePath) + if (!File(absoluteFilePath).exists()) { + Log.e(TAG, "No such file: ${fileSource.filePath}") + return null + } + + VideoPlayerHolder.stopAll() + RecorderInterface.stopRecording?.invoke() + val current = currentlyPlaying.value + if (current == null || current.first != fileSource) { + stopListener() + player.stop() + runCatching { + if (fileSource.cryptoArgs != null) { + val tmpFile = fileSource.createTmpFileIfNeeded() + decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath) + player.media().prepare("file://${tmpFile.absolutePath}") + } else { + player.media().prepare("file://$absoluteFilePath") + } + }.onFailure { + Log.e(TAG, it.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message) + return null + } + } + if (seek != null) player.seekTo(seek) + player.start() + currentlyPlaying.value = fileSource to onProgressUpdate + progressJob = CoroutineScope(Dispatchers.Default).launch { + onProgressUpdate(player.currentPosition, TrackState.PLAYING) + while(isActive && (player.isPlaying || player.status().state() == State.OPENING)) { + // Even when current position is equal to duration, the player has isPlaying == true for some time, + // so help to make the playback stopped in UI immediately + if (player.currentPosition == player.duration) { + onProgressUpdate(player.currentPosition, TrackState.PLAYING) + break + } + delay(50) + onProgressUpdate(player.currentPosition, TrackState.PLAYING) + } + onProgressUpdate(null, TrackState.PAUSED) + currentlyPlaying.value?.first?.deleteTmpFile() + } + return player.duration + } + + private fun pause(): Int { + progressJob?.cancel() + progressJob = null + val position = player.currentPosition + player.pause() + return position } override fun stop() { - /*LALAL*/ + if (currentlyPlaying.value == null) return + player.stop() + stopListener() } - override fun stop(item: ChatItem) { - /*LALAL*/ - } + override fun stop(item: ChatItem) = stop(item.file?.fileName) + // FileName or filePath are ok override fun stop(fileName: String?) { - TODO("Not yet implemented") + if (fileName != null && currentlyPlaying.value?.first?.filePath?.endsWith(fileName) == true) { + stop() + } + } + + private fun stopListener() { + val afterCoroutineCancel: CompletionHandler = { + // Notify prev audio listener about stop + currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED) + currentlyPlaying.value?.first?.deleteTmpFile() + currentlyPlaying.value = null + } + /** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be: + * [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order) + * */ + if (progressJob != null) { + progressJob?.invokeOnCompletion(afterCoroutineCancel) + } else { + afterCoroutineCancel(null) + } + progressJob?.cancel() + progressJob = null + } + + override fun play( + fileSource: CryptoFile, + audioPlaying: MutableState, + progress: MutableState, + duration: MutableState, + resetOnEnd: Boolean, + ) { + if (progress.value == duration.value) { + progress.value = 0 + } + val realDuration = start(fileSource, progress.value) { pro, state -> + if (pro != null) { + progress.value = pro + } + if (pro == null || pro == duration.value) { + audioPlaying.value = false + if (pro == duration.value) { + progress.value = if (resetOnEnd) 0 else duration.value + } else if (state == TrackState.REPLACED) { + progress.value = 0 + } + } + } + audioPlaying.value = realDuration != null + // Update to real duration instead of what was received in ChatInfo + realDuration?.let { duration.value = it } } override fun pause(audioPlaying: MutableState, pro: MutableState) { - TODO("Not yet implemented") + pro.value = pause() + audioPlaying.value = false } override fun seekTo(ms: Int, pro: MutableState, filePath: String?) { - /*LALAL*/ + pro.value = ms + if (currentlyPlaying.value?.first?.filePath == filePath) { + player.seekTo(ms) + } } override fun duration(unencryptedFilePath: String): Int? { - /*LALAL*/ - return null + var res: Int? = null + try { + val helperPlayer = AudioPlayerComponent().mediaPlayer() + helperPlayer.media().startPaused("file://$unencryptedFilePath") + res = helperPlayer.duration + helperPlayer.stop() + helperPlayer.release() + } catch (e: Exception) { + Log.e(TAG, e.stackTraceToString()) + } + return res } } +val MediaPlayer.isPlaying: Boolean + get() = status().isPlaying + +fun MediaPlayer.seekTo(time: Int) { + controls().setTime(time.toLong()) +} + +fun MediaPlayer.start() { + controls().start() +} + +fun MediaPlayer.pause() { + controls().pause() +} + +fun MediaPlayer.stop() { + controls().stop() +} + +private val MediaPlayer.currentPosition: Int + get() = max(0, status().time().toInt()) + +val MediaPlayer.duration: Int + get() = media().info().duration().toInt() + actual object SoundPlayer: SoundPlayerInterface { override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } override fun stop() { /*LALAL*/ } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 90f6a593f..e590c2e20 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 @@ -3,51 +3,214 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.ImageBitmap -import chat.simplex.common.views.usersettings.showInDevelopingAlert +import androidx.compose.ui.graphics.toComposeImageBitmap +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.* +import uk.co.caprica.vlcj.player.base.MediaPlayer +import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent +import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent +import java.awt.Component +import java.io.File import java.net.URI +import kotlin.math.max -actual class VideoPlayer: VideoPlayerInterface { - actual companion object { - actual fun getOrCreate( - uri: URI, - gallery: Boolean, - defaultPreview: ImageBitmap, - defaultDuration: Long, - soundEnabled: Boolean - ): VideoPlayer = VideoPlayer().also { - it.preview.value = defaultPreview - it.duration.value = defaultDuration - it.soundEnabled.value = soundEnabled - } - actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean { /*TODO*/ return false } - actual fun release(uri: URI, gallery: Boolean, remove: Boolean) { /*TODO*/ } - actual fun stopAll() { /*LALAL*/ } - actual fun releaseAll() { /*LALAL*/ } - } - +actual class VideoPlayer actual constructor( + override val uri: URI, + override val gallery: Boolean, + private val defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean +): VideoPlayerInterface { override val soundEnabled: MutableState = mutableStateOf(false) override val brokenVideo: MutableState = mutableStateOf(false) override val videoPlaying: MutableState = mutableStateOf(false) override val progress: MutableState = mutableStateOf(0L) override val duration: MutableState = mutableStateOf(0L) - override val preview: MutableState = mutableStateOf(ImageBitmap(0, 0)) + override val preview: MutableState = mutableStateOf(defaultPreview) + + val mediaPlayerComponent = initializeMediaPlayerComponent() + val player by lazy { mediaPlayerComponent.mediaPlayer() } + + init { + withBGApi { + setPreviewAndDuration() + } + } + + private val currentVolume: Int by lazy { player.audio().volume() } + private var isReleased: Boolean = false + + private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null) + private var progressJob: Job? = null + + enum class TrackState { + PLAYING, PAUSED, STOPPED + } + + private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean { + val filepath = getAppFilePath(uri) + if (filepath == null || !File(filepath).exists()) { + Log.e(TAG, "No such file: $uri") + brokenVideo.value = true + return false + } + + if (soundEnabled.value) { + RecorderInterface.stopRecording?.invoke() + } + AudioPlayer.stop() + VideoPlayerHolder.stopAll() + val playerFilePath = uri.toString().replaceFirst("file:", "file://") + if (listener.value == null) { + runCatching { + player.media().prepare(playerFilePath) + if (seek != null) { + player.seekTo(seek.toInt()) + } + }.onFailure { + Log.e(TAG, it.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message) + brokenVideo.value = true + return false + } + } + player.start() + if (seek != null) player.seekTo(seek.toInt()) + if (!player.isPlaying) { + // Can happen when video file is broken + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error)) + brokenVideo.value = true + return false + } + listener.value = onProgressUpdate + // Player can only be accessed in one specific thread + progressJob = CoroutineScope(Dispatchers.Main).launch { + onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) + while (isActive && !isReleased && player.isPlaying) { + // Even when current position is equal to duration, the player has isPlaying == true for some time, + // so help to make the playback stopped in UI immediately + if (player.currentPosition == player.duration) { + onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) + break + } + delay(50) + onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) + } + if (isActive && !isReleased) { + onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED) + } + onProgressUpdate(null, TrackState.PAUSED) + } + + return true + } override fun stop() { - /*TODO*/ + if (isReleased || !videoPlaying.value) return + player.controls().stop() + stopListener() + } + + private fun stopListener() { + val afterCoroutineCancel: CompletionHandler = { + // Notify prev video listener about stop + listener.value?.invoke(null, TrackState.STOPPED) + } + /** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be: + * [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.STOPPED] (in this order) + * */ + if (progressJob != null) { + progressJob?.invokeOnCompletion(afterCoroutineCancel) + } else { + afterCoroutineCancel(null) + } + progressJob?.cancel() + progressJob = null } override fun play(resetOnEnd: Boolean) { - if (appPlatform.isDesktop) { - showInDevelopingAlert() + if (progress.value == duration.value) { + progress.value = 0 + } + videoPlaying.value = start(progress.value) { pro, _ -> + if (pro != null) { + progress.value = pro + } + if ((pro == null || pro == duration.value) && duration.value != 0L) { + videoPlaying.value = false + if (pro == duration.value) { + progress.value = if (resetOnEnd) 0 else duration.value + }/* else if (state == TrackState.STOPPED) { + progress.value = 0 // + }*/ + } } } override fun enableSound(enable: Boolean): Boolean { - /*TODO*/ - return false + if (isReleased) return false + if (soundEnabled.value == enable) return false + soundEnabled.value = enable + player.audio().setVolume(if (enable) currentVolume else 0) + return true } - override fun release(remove: Boolean) { - /*TODO*/ + override fun release(remove: Boolean) { withApi { + if (isReleased) return@withApi + isReleased = true + // TODO + /** [player.release] freezes thread for some reason. It happens periodically. So doing this we don't see the freeze, but it's still there */ + if (player.isPlaying) player.stop() + CoroutineScope(Dispatchers.IO).launch { player.release() } + if (remove) { + VideoPlayerHolder.players.remove(uri to gallery) + } + }} + + private val MediaPlayer.currentPosition: Int + get() = if (isReleased) 0 else max(0, player.status().time().toInt()) + + private suspend fun setPreviewAndDuration() { + // It freezes main thread, doing it in IO thread + CoroutineScope(Dispatchers.IO).launch { + val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) } + withContext(Dispatchers.Main) { + preview.value = previewAndDuration.preview ?: defaultPreview + duration.value = (previewAndDuration.duration ?: 0) + } + } + } + + private fun initializeMediaPlayerComponent(): Component { + return if (desktopPlatform.isMac()) { + CallbackMediaPlayerComponent() + } else { + EmbeddedMediaPlayerComponent() + } + } + + private fun Component.mediaPlayer() = when (this) { + is CallbackMediaPlayerComponent -> mediaPlayer() + is EmbeddedMediaPlayerComponent -> mediaPlayer() + else -> error("mediaPlayer() can only be called on vlcj player components") + } + + companion object { + suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration { + val player = CallbackMediaPlayerComponent().mediaPlayer() + if (uri == null || !File(uri.rawPath).exists()) { + return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) + } + player.media().startPaused(uri.toString().replaceFirst("file:", "file://")) + val start = System.currentTimeMillis() + while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) { + delay(10) + } + val preview = player.snapshots()?.get()?.toComposeImageBitmap() + val duration = player.duration.toLong() + CoroutineScope(Dispatchers.IO).launch { player.release() } + return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) + } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index aac995e48..c85057b47 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -6,9 +6,7 @@ import androidx.compose.ui.unit.Dp import chat.simplex.common.platform.VideoPlayer @Composable -actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { - /* LALAL */ -} +actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {} @Composable actual fun LocalWindowWidth(): Dp { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index a73c2784e..9aafc83d2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -1,14 +1,23 @@ package chat.simplex.common.views.chat.item -import androidx.compose.foundation.Image -import androidx.compose.runtime.Composable +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale -import chat.simplex.common.platform.VideoPlayer +import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.* +import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.getBitmapFromByteArray import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import kotlin.math.max @Composable actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { @@ -20,6 +29,43 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ) } @Composable -actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) { - +actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) { + // Workaround. Without changing size of the window the screen flashes a lot even if it's not being recomposed + LaunchedEffect(Unit) { + simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width + 1.dp) + delay(50) + player.play(true) + simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width - 1.dp) + } + Box { + Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) { + val factory = remember { { player.mediaPlayerComponent } } + SwingPanel( + background = Color.Transparent, + modifier = Modifier, + factory = factory + ) + } + Controls(player, close) + } +} + +@Composable +private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) { + val playing = remember(player) { player.videoPlaying } + val progress = remember(player) { player.progress } + val duration = remember(player) { player.duration } + Row(Modifier.fillMaxWidth().align(Alignment.BottomCenter).height(50.dp)) { + IconButton(onClick = { if (playing.value) player.player.pause() else player.play(true) },) { + Icon(painterResource(if (playing.value) MR.images.ic_pause_filled else MR.images.ic_play_arrow_filled), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary) + } + Slider( + value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()), + onValueChange = { player.player.seekTo((it * duration.value).toInt()) }, + modifier = Modifier.fillMaxWidth().weight(1f) + ) + IconButton(onClick = close,) { + Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary) + } + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt index 32f5a0a80..d125f94e5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt @@ -11,10 +11,14 @@ import dev.icerock.moko.resources.compose.stringResource @Composable actual fun ChooseAttachmentButtons(attachmentOption: MutableState, hide: () -> Unit) { - ActionButton(Modifier.fillMaxWidth(0.5f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { + ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { attachmentOption.value = AttachmentOption.GalleryImage hide() } + ActionButton(Modifier.fillMaxWidth(0.5f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) { + attachmentOption.value = AttachmentOption.GalleryVideo + hide() + } ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { attachmentOption.value = AttachmentOption.File hide() diff --git a/apps/multiplatform/common/src/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 4fa768a5d..21b2cfa6e 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 @@ -132,9 +132,8 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) } else null } -actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { - // LALAL - return VideoPlayerInterface.PreviewAndDuration(preview = null, timestamp = 0L, duration = 0L) +actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { + return VideoPlayer.getBitmapFromVideo(null, uri) } @OptIn(ExperimentalEncodingApi::class) diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index dc4aa89fb..39d08e046 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -1,6 +1,5 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.util.capitalizeDecapitalize.toLowerCaseAsciiOnly -import java.util.* plugins { kotlin("multiplatform") @@ -22,6 +21,7 @@ kotlin { dependencies { implementation(project(":common")) implementation(compose.desktop.currentOs) + implementation("net.java.dev.jna:jna:5.13.0") } } val jvmTest by getting @@ -33,16 +33,23 @@ compose { desktop { application { // For debugging via VisualVM - /*jvmArgs += listOf( - "-Dcom.sun.management.jmxremote.port=8080", - "-Dcom.sun.management.jmxremote.ssl=false", - "-Dcom.sun.management.jmxremote.authenticate=false" - )*/ + val debugJava = false + if (debugJava) { + jvmArgs += listOf( + "-Dcom.sun.management.jmxremote.port=8080", + "-Dcom.sun.management.jmxremote.ssl=false", + "-Dcom.sun.management.jmxremote.authenticate=false" + ) + } mainClass = "chat.simplex.desktop.MainKt" nativeDistributions { // For debugging via VisualVM - //modules("jdk.zipfs", "jdk.management.agent") - modules("jdk.zipfs") + if (debugJava) { + modules("jdk.zipfs", "jdk.unsupported", "jdk.management.agent") + } else { + // 'jdk.unsupported' is for vlcj + modules("jdk.zipfs", "jdk.unsupported") + } //includeAllModules = true outputBaseDir.set(project.file("../release")) targetFormats( @@ -145,57 +152,119 @@ tasks.named("compileJava") { afterEvaluate { tasks.create("cmakeBuildAndCopy") { dependsOn("cmakeBuild") + val copyDetails = mutableMapOf>() + copy { + from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") + into("src/jvmMain/resources/libs/linux-x86_64") + include("*.so*") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc" + from("$cppPath/desktop/libs/linux-x86_64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps") + into("src/jvmMain/resources/libs/linux-aarch64") + include("*.so*") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc" + from("$cppPath/desktop/libs/linux-aarch64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") + into("src/jvmMain/resources/libs/windows-x86_64") + include("*.dll") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc" + from("$cppPath/desktop/libs/windows-x86_64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps") + into("src/jvmMain/resources/libs/mac-x86_64") + include("*.dylib") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc" + from("$cppPath/desktop/libs/mac-x86_64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } + copy { + from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps") + into("src/jvmMain/resources/libs/mac-aarch64") + include("*.dylib") + eachFile { + path = name + } + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + copy { + val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc" + from("$cppPath/desktop/libs/mac-aarch64/deps/vlc") + into(destinationDir) + includeEmptyDirs = false + duplicatesStrategy = DuplicatesStrategy.INCLUDE + copyIfNeeded(destinationDir, copyDetails) + } doLast { - copy { - from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") - into("src/jvmMain/resources/libs/linux-x86_64") - include("*.so*") - eachFile { - path = name + copyDetails.forEach { (destinationDir, details) -> + details.forEach { detail -> + val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path) + if (target.exists()) { + target.setLastModified(detail.lastModified) + } } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps") - into("src/jvmMain/resources/libs/linux-aarch64") - include("*.so*") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") - into("src/jvmMain/resources/libs/windows-x86_64") - include("*.dll") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps") - into("src/jvmMain/resources/libs/mac-x86_64") - include("*.dylib") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE - } - copy { - from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps") - into("src/jvmMain/resources/libs/mac-aarch64") - include("*.dylib") - eachFile { - path = name - } - includeEmptyDirs = false - duplicatesStrategy = DuplicatesStrategy.INCLUDE } } } } + +fun CopySpec.copyIfNeeded(destinationDir: String, into: MutableMap>) { + val details = arrayListOf() + eachFile { + val targetFile = File(destinationDir, path) + if (file.lastModified() == targetFile.lastModified() && file.length() == targetFile.length()) { + exclude() + } else { + details.add(this) + } + } + into[destinationDir] = details +} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 4d97bc49b..72c41a665 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -5,6 +5,8 @@ import chat.simplex.common.showApp import java.io.File import java.nio.file.* import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime +import kotlin.io.path.setLastModifiedTime fun main() { initHaskell() @@ -20,6 +22,15 @@ private fun initHaskell() { val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) System.load(File(libsTmpDir, libApp).absolutePath) + + vlcDir.deleteRecursively() + Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING) + // No picture without preloading it, only sound. However, with libs from AppImage it works without preloading + //val libXcb = "libvlc_xcb_events.so.0.0.0" + //System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath) + System.setProperty("jna.library.path", vlcDir.absolutePath) + //discoverVlcLibs(File(File(vlcDir, "vlc"), "plugins").absolutePath) + libsTmpDir.deleteRecursively() initHS() } @@ -34,7 +45,12 @@ private fun copyResources(from: String, to: Path) { return FileVisitResult.CONTINUE } override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - Files.copy(file, to.resolve(resPath.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING) + val dest = to.resolve(resPath.relativize(file).toString()) + Files.copy(file, dest, StandardCopyOption.REPLACE_EXISTING) + // Setting the same time on file as the time set in script that generates VLC libs + if (dest.toString().contains("." + desktopPlatform.libExtension)) { + dest.setLastModifiedTime(FileTime.fromMillis(0)) + } return FileVisitResult.CONTINUE } }) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index e8ca8faa2..f05641165 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.3-beta.8 -android.version_code=150 +android.version_name=5.3-beta.9 +android.version_code=151 -desktop.version_name=1.6.0 -desktop.version_code=8 +desktop.version_name=1.7.0 +desktop.version_code=9 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 948fecfc2..4d120b671 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -15,28 +15,28 @@ revision: 20.09.2023 desktop app -The latest version of desktop app is v5.3-beta.8 (1.6.0 in the app). +The latest version of desktop app is v5.3-beta.9 (1.6.0 in the app). Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: coming soon. ## Mobile apps -**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084) (v5.2.3), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (v5.3-beta.8). +**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084) (v5.2.3), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (v5.3-beta.9). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.8/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-windows-x86-64). diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index 41ca8a64f..f69b89773 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -23,3 +23,4 @@ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +scripts/desktop/prepare-vlc-linux.sh diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 5a8ac3d3f..b5d738be9 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -77,3 +77,4 @@ rm -rf apps/multiplatform/desktop/build/cmake mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ +scripts/desktop/prepare-vlc-mac.sh diff --git a/scripts/desktop/make-appimage-linux.sh b/scripts/desktop/make-appimage-linux.sh index e71b097c7..35e62481d 100755 --- a/scripts/desktop/make-appimage-linux.sh +++ b/scripts/desktop/make-appimage-linux.sh @@ -12,7 +12,7 @@ release_app_dir=$root_dir/apps/multiplatform/release/main/app cd $multiplatform_dir libcrypto_path=$(ldd common/src/commonMain/cpp/desktop/libs/*/deps/libHSdirect-sqlcipher-*.so | grep libcrypto | cut -d'=' -f 2 | cut -d ' ' -f 2) -trap "rm common/src/commonMain/cpp/desktop/libs/*/deps/`basename $libcrypto_path` 2> /dev/null" EXIT +trap "rm common/src/commonMain/cpp/desktop/libs/*/deps/`basename $libcrypto_path` 2> /dev/null || true" EXIT cp $libcrypto_path common/src/commonMain/cpp/desktop/libs/*/deps ./gradlew createDistributable diff --git a/scripts/desktop/prepare-vlc-linux.sh b/scripts/desktop/prepare-vlc-linux.sh new file mode 100755 index 000000000..e1cfa7e9f --- /dev/null +++ b/scripts/desktop/prepare-vlc-linux.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +set -e + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64/deps/vlc + +mkdir $vlc_dir || exit 0 + + +cd /tmp +mkdir tmp 2>/dev/null || true +cd tmp +curl https://github.com/cmatomic/VLCplayer-AppImage/releases/download/3.0.11.1/VLC_media_player-3.0.11.1-x86_64.AppImage -L -o appimage +chmod +x appimage +./appimage --appimage-extract +cp -r squashfs-root/usr/lib/* $vlc_dir +cd ../ +rm -rf tmp +exit 0 + + +# This is currently unneeded +cd /tmp +( +mkdir tmp +cd tmp +curl http://archive.ubuntu.com/ubuntu/pool/universe/v/vlc/libvlc5_3.0.9.2-1_amd64.deb -o libvlc +ar p libvlc data.tar.xz > data.tar.xz +tar -xvf data.tar.xz +mv usr/lib/x86_64-linux-gnu/libvlc.so{.5,} +cp usr/lib/x86_64-linux-gnu/libvlc.so* $vlc_dir +cd ../ +rm -rf tmp +) + +( +mkdir tmp +cd tmp +curl http://archive.ubuntu.com/ubuntu/pool/universe/v/vlc/libvlccore9_3.0.9.2-1_amd64.deb -o libvlccore +ar p libvlccore data.tar.xz > data.tar.xz +tar -xvf data.tar.xz +cp usr/lib/x86_64-linux-gnu/libvlccore.so* $vlc_dir +cd ../ +rm -rf tmp +) + +( +mkdir tmp +cd tmp +curl http://mirrors.edge.kernel.org/ubuntu/pool/universe/v/vlc/vlc-plugin-base_3.0.9.2-1_amd64.deb -o plugins +ar p plugins data.tar.xz > data.tar.xz +tar -xvf data.tar.xz +find usr/lib/x86_64-linux-gnu/vlc/plugins/ -name "lib*.so*" -exec patchelf --set-rpath '$ORIGIN/../../' {} \; +cp -r usr/lib/x86_64-linux-gnu/vlc/{libvlc*,plugins} $vlc_dir +cd ../ +rm -rf tmp +) + +( +mkdir tmp +cd tmp +curl http://archive.ubuntu.com/ubuntu/pool/main/libi/libidn/libidn11_1.33-2.2ubuntu2_amd64.deb -o idn +ar p idn data.tar.xz > data.tar.xz +tar -xvf data.tar.xz +cp lib/x86_64-linux-gnu/lib* $vlc_dir +cd ../ +rm -rf tmp +) + +find $vlc_dir -maxdepth 1 -name "lib*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \; diff --git a/scripts/desktop/prepare-vlc-mac.sh b/scripts/desktop/prepare-vlc-mac.sh new file mode 100755 index 000000000..69644bcc1 --- /dev/null +++ b/scripts/desktop/prepare-vlc-mac.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -e + +ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" +if [ "$ARCH" == "arm64" ]; then + ARCH=aarch64 + vlc_arch=arm64 +else + vlc_arch=intel64 +fi +vlc_version=3.0.19 + +function readlink() { + echo "$(cd "$(dirname "$1")"; pwd -P)" +} + +root_dir="$(dirname "$(dirname "$(readlink "$0")")")" +vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-$ARCH/deps/vlc +#rm -rf $vlc_dir +mkdir -p $vlc_dir/vlc || exit 0 + +cd /tmp +mkdir tmp 2>/dev/null || true +cd tmp +curl https://github.com/simplex-chat/vlc/releases/download/v$vlc_version/vlc-macos-$ARCH.zip -L -o vlc +unzip -oqq vlc +install_name_tool -add_rpath "@loader_path/VLC.app/Contents/MacOS/lib" vlc-cache-gen +cd VLC.app/Contents/MacOS/lib +for lib in $(ls *.dylib); do install_name_tool -add_rpath "@loader_path" $lib 2> /dev/null || true; done +cd ../plugins +for lib in $(ls *.dylib); do + install_name_tool -add_rpath "@loader_path/../../" $lib 2> /dev/null || true +done +cd .. +../../../vlc-cache-gen plugins +cp lib/* $vlc_dir/ +cp -r -p plugins/ $vlc_dir/vlc/plugins +cd ../../../../ +rm -rf tmp diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 57113dea6..700548bb1 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -14,14 +14,12 @@ import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J import Data.Bifunctor (first) import qualified Data.ByteString.Base64.URL as U +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import qualified Data.ByteString.Lazy.Char8 as LB import Data.Functor (($>)) import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) -import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) import Data.Word (Word8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB @@ -95,36 +93,36 @@ cChatMigrateInit fp key conf ctrl = do chatMigrateInit dbPath dbKey confirm >>= \case Right cc -> (newStablePtr cc >>= poke ctrl) $> DBMOk Left e -> pure e - newCAString . LB.unpack $ J.encode r + newCStringFromLazyBS $ J.encode r -- | send command to chat (same syntax as in terminal for now) cChatSendCmd :: StablePtr ChatController -> CString -> IO CJSONString cChatSendCmd cPtr cCmd = do c <- deRefStablePtr cPtr - cmd <- peekCAString cCmd - newCAString =<< chatSendCmd c cmd + cmd <- B.packCString cCmd + newCStringFromLazyBS =<< chatSendCmd c cmd -- | receive message from chat (blocking) cChatRecvMsg :: StablePtr ChatController -> IO CJSONString -cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCAString +cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCStringFromLazyBS -- | receive message from chat (blocking up to `t` microseconds (1/10^6 sec), returns empty string if times out) cChatRecvMsgWait :: StablePtr ChatController -> CInt -> IO CJSONString -cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t) >>= newCAString +cChatRecvMsgWait cc t = deRefStablePtr cc >>= (`chatRecvMsgWait` fromIntegral t) >>= newCStringFromLazyBS -- | parse markdown - returns ParsedMarkdown type JSON cChatParseMarkdown :: CString -> IO CJSONString -cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s +cChatParseMarkdown s = newCStringFromLazyBS . chatParseMarkdown =<< B.packCString s -- | parse server address - returns ParsedServerAddress JSON cChatParseServer :: CString -> IO CJSONString -cChatParseServer s = newCAString . chatParseServer =<< peekCAString s +cChatParseServer s = newCStringFromLazyBS . chatParseServer =<< B.packCString s cChatPasswordHash :: CString -> CString -> IO CString cChatPasswordHash cPwd cSalt = do - pwd <- peekCAString cPwd - salt <- peekCAString cSalt - newCAString $ chatPasswordHash pwd salt + pwd <- B.packCString cPwd + salt <- B.packCString cSalt + newCStringFromBS $ chatPasswordHash pwd salt mobileChatOpts :: String -> String -> ChatOpts mobileChatOpts dbFilePrefix dbKey = @@ -197,22 +195,22 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do _ -> dbError e dbError e = Left . DBMErrorSQL dbFile $ show e -chatSendCmd :: ChatController -> String -> IO JSONString -chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc +chatSendCmd :: ChatController -> ByteString -> IO JSONByteString +chatSendCmd cc s = J.encode . APIResponse Nothing <$> runReaderT (execChatCommand s) cc -chatRecvMsg :: ChatController -> IO JSONString +chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) where - json (corr, resp) = LB.unpack $ J.encode APIResponse {corr, resp} + json (corr, resp) = J.encode APIResponse {corr, resp} -chatRecvMsgWait :: ChatController -> Int -> IO JSONString +chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) -chatParseMarkdown :: String -> JSONString -chatParseMarkdown = LB.unpack . J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 . B.pack +chatParseMarkdown :: ByteString -> JSONByteString +chatParseMarkdown = J.encode . ParsedMarkdown . parseMaybeMarkdownList . safeDecodeUtf8 -chatParseServer :: String -> JSONString -chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack +chatParseServer :: ByteString -> JSONByteString +chatParseServer = J.encode . toServerAddress . strDecode where toServerAddress :: Either String AProtoServerWithAuth -> ParsedServerAddress toServerAddress = \case @@ -223,11 +221,11 @@ chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack enc :: StrEncoding a => a -> String enc = B.unpack . strEncode -chatPasswordHash :: String -> String -> String +chatPasswordHash :: ByteString -> ByteString -> ByteString chatPasswordHash pwd salt = either (const "") passwordHash salt' where - salt' = U.decode $ B.pack salt - passwordHash = B.unpack . U.encode . C.sha512Hash . (encodeUtf8 (T.pack pwd) <>) + salt' = U.decode salt + passwordHash = U.encode . C.sha512Hash . (pwd <>) data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} deriving (Generic) diff --git a/src/Simplex/Chat/Mobile/File.hs b/src/Simplex/Chat/Mobile/File.hs index 0cf4d0fef..f385e14f4 100644 --- a/src/Simplex/Chat/Mobile/File.hs +++ b/src/Simplex/Chat/Mobile/File.hs @@ -25,11 +25,11 @@ import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy.Char8 as LB' import Data.Char (chr) import Data.Either (fromLeft) -import Data.Word (Word8, Word32) +import Data.Word (Word32, Word8) import Foreign.C import Foreign.Marshal.Alloc (mallocBytes) import Foreign.Ptr -import Foreign.Storable (poke) +import Foreign.Storable (poke, pokeByteOff) import GHC.Generics (Generic) import Simplex.Chat.Mobile.Shared import Simplex.Chat.Util (chunkSize, encryptFile) @@ -52,7 +52,7 @@ cChatWriteFile cPath ptr len = do path <- peekCString cPath s <- getByteString ptr len r <- chatWriteFile path s - newCAString $ LB'.unpack $ J.encode r + newCStringFromLazyBS $ J.encode r chatWriteFile :: FilePath -> ByteString -> IO WriteFileResult chatWriteFile path s = do @@ -76,12 +76,11 @@ cChatReadFile cPath cKey cNonce = do chatReadFile path key nonce >>= \case Left e -> castPtr <$> newCString (chr 1 : e) Right s -> do - let s' = LB.toStrict s - len = B.length s' + let len = fromIntegral $ LB.length s ptr <- mallocBytes $ len + 5 - poke ptr 0 - poke (ptr `plusPtr` 1) (fromIntegral len :: Word32) - putByteString (ptr `plusPtr` 5) s' + poke ptr (0 :: Word8) + pokeByteOff ptr 1 (fromIntegral len :: Word32) + putLazyByteString (ptr `plusPtr` 5) s pure ptr chatReadFile :: FilePath -> ByteString -> ByteString -> IO (Either String LB.ByteString) diff --git a/src/Simplex/Chat/Mobile/Shared.hs b/src/Simplex/Chat/Mobile/Shared.hs index a73a25fb6..d0c5b0b86 100644 --- a/src/Simplex/Chat/Mobile/Shared.hs +++ b/src/Simplex/Chat/Mobile/Shared.hs @@ -1,19 +1,48 @@ +{-# LANGUAGE LambdaCase #-} + module Simplex.Chat.Mobile.Shared where import qualified Data.ByteString as B -import Data.ByteString.Internal (ByteString (PS), memcpy) +import Data.ByteString.Internal (ByteString (..), memcpy) +import qualified Data.ByteString.Lazy as LB +import qualified Data.ByteString.Lazy.Internal as LB import Foreign.C (CInt, CString) -import Foreign (Ptr, Word8, newForeignPtr_, plusPtr) -import Foreign.ForeignPtr.Unsafe +import Foreign type CJSONString = CString +type JSONByteString = LB.ByteString + getByteString :: Ptr Word8 -> CInt -> IO ByteString getByteString ptr len = do fp <- newForeignPtr_ ptr - pure $ PS fp 0 $ fromIntegral len + pure $ BS fp $ fromIntegral len +{-# INLINE getByteString #-} putByteString :: Ptr Word8 -> ByteString -> IO () -putByteString ptr bs@(PS fp offset _) = do - let p = unsafeForeignPtrToPtr fp `plusPtr` offset - memcpy ptr p $ B.length bs +putByteString ptr (BS fp len) = + withForeignPtr fp $ \p -> memcpy ptr p len +{-# INLINE putByteString #-} + +putLazyByteString :: Ptr Word8 -> LB.ByteString -> IO () +putLazyByteString ptr = \case + LB.Empty -> pure () + LB.Chunk ch s -> do + putByteString ptr ch + putLazyByteString (ptr `plusPtr` B.length ch) s + +newCStringFromBS :: ByteString -> IO CString +newCStringFromBS s = do + let len = B.length s + buf <- mallocBytes (len + 1) + putByteString buf s + pokeByteOff buf len (0 :: Word8) + pure $ castPtr buf + +newCStringFromLazyBS :: LB.ByteString -> IO CString +newCStringFromLazyBS s = do + let len = fromIntegral $ LB.length s + buf <- mallocBytes (len + 1) + putLazyByteString buf s + pokeByteOff buf len (0 :: Word8) + pure $ castPtr buf diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7bcd16967..dea948e84 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1391,8 +1391,6 @@ serializeIntroStatus = \case data Notification = Notification {title :: Text, text :: Text} -type JSONString = String - textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 07d0d2fe9..0b965250b 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# OPTIONS_GHC -fno-warn-orphans #-} @@ -61,66 +62,66 @@ mobileTests = do it "utf8 name 2" $ testFileEncryptionCApi "👍" it "no exception on missing file" testMissingFileEncryptionCApi -noActiveUser :: String +noActiveUser :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) noActiveUser = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"noActiveUser\":{}}}}}}}" #else noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" #endif -activeUserExists :: String +activeUserExists :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" #else activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" #endif -activeUser :: String +activeUser :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" #else activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" #endif -chatStarted :: String +chatStarted :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) chatStarted = "{\"resp\":{\"chatStarted\":{}}}" #else chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" #endif -contactSubSummary :: String +contactSubSummary :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) contactSubSummary = "{\"resp\":{\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" #else contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" #endif -memberSubSummary :: String +memberSubSummary :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) memberSubSummary = "{\"resp\":{\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" #else memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" #endif -userContactSubSummary :: String +userContactSubSummary :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" #else userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" #endif -pendingSubSummary :: String +pendingSubSummary :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" #else pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" #endif -userJSON :: String +userJSON :: LB.ByteString userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" -parsedMarkdown :: String +parsedMarkdown :: LB.ByteString #if defined(darwin_HOST_OS) && defined(swiftJSON) parsedMarkdown = "{\"formattedText\":[{\"format\":{\"bold\":{}},\"text\":\"hello\"}]}" #else diff --git a/website/src/blogs-atom-feed.njk b/website/src/blogs-atom-feed.njk index 849cedcf1..11e1d72a7 100644 --- a/website/src/blogs-atom-feed.njk +++ b/website/src/blogs-atom-feed.njk @@ -23,6 +23,7 @@ metadata: {{ metadata.author.email }} {%- for blog in collections.blogs | reverse %} + {%- if not blog.data.draft %} {%- set absolutePostUrl = blog.url | absoluteUrl(metadata.url) %} {{ blog.data.title }} @@ -33,5 +34,6 @@ metadata: {{ blog.templateContent | htmlToAbsoluteUrls(absolutePostUrl) }} {# {{ blog.templateContent | striptags | truncate(200) }} #} + {%- endif %} {%- endfor %} \ No newline at end of file diff --git a/website/src/blogs-rss-feed.njk b/website/src/blogs-rss-feed.njk index c84362eab..54b63e088 100644 --- a/website/src/blogs-rss-feed.njk +++ b/website/src/blogs-rss-feed.njk @@ -19,6 +19,7 @@ metadata: {{ metadata.subtitle }} {{ metadata.language }} {%- for blog in collections.blogs %} + {%- if not blog.data.draft %} {%- set absolutePostUrl = blog.url | absoluteUrl(metadata.url) %} {{ blog.data.title }} @@ -30,6 +31,7 @@ metadata: {{ metadata.author.name }} {{ absolutePostUrl }} + {%- endif %} {%- endfor %} \ No newline at end of file